Skill 14 · Building With Medusa
Subchapter 14.10
reference/subscribers-and-events.mdMarkdown15 KBView on GitHub
Subscribers are asynchronous functions that execute when specific events are emitted. Use them to perform actions after commerce operations, like sending confirmation emails when an order is placed.
Use subscribers when you need to react to events that happen in your application:
Don’t use subscribers for:
Subscribers vs Scheduled Jobs:
order.placed event and sends confirmation email (event-driven)Create a TypeScript file in the src/subscribers/ directory:
// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
export default async function orderPlacedHandler({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
logger.info(`Order ${data.id} was placed`)
// Resolve services
const orderService = container.resolve(Modules.ORDER)
const notificationService = container.resolve(Modules.NOTIFICATION)
// Retrieve full order data
const order = await orderService.retrieveOrder(data.id, {
relations: ["customer", "items"],
})
// Send confirmation email
await notificationService.createNotifications({
to: order.customer.email,
template: "order-confirmation",
channel: "email",
data: { order },
})
logger.info(`Confirmation email sent for order ${data.id}`)
}
export const config: SubscriberConfig = {
event: "order.placed", // Single event
}// src/subscribers/product-changes.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export default async function productChangesHandler({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
logger.info(`Product event: ${eventName} for product ${data.id}`)
// Handle different events
switch (eventName) {
case "product.created":
// Handle product creation
break
case "product.updated":
// Handle product update
break
case "product.deleted":
// Handle product deletion
break
}
}
export const config: SubscriberConfig = {
event: ["product.created", "product.updated", "product.deleted"],
}⚠️ IMPORTANT: Event data typically contains only the ID of the affected entity. You must retrieve the full data if needed.
"order.placed" // Order was placed
"order.updated" // Order was updated
"order.canceled" // Order was canceled
"order.completed" // Order was completed
"order.shipment_created" // Shipment was created for order"product.created" // Product was created
"product.updated" // Product was updated
"product.deleted" // Product was deleted"customer.created" // Customer was created
"customer.updated" // Customer was updated"cart.created" // Cart was created
"cart.updated" // Cart was updatedAll of the events below were added in v2.17.3 — they don’t exist in earlier versions.
"inventory-item.created" // Inventory items were created
"inventory-item.updated" // Inventory items were updated
"inventory-item.deleted" // Inventory items were deleted
"inventory-level.created" // Inventory levels were created
"inventory-level.updated" // Stocked/reserved quantity changed (e.g. during fulfillment)
"inventory-level.deleted" // Inventory levels were deletedUse these to sync stock to external systems or alert on low levels. inventory-level.updated carries { id, order_id? } — order_id is set when the change came from an order flow.
"auth.password_reset" // Password reset was requested
"auth.verification_requested" // A verification code was generated (v2.15.5+)
"auth.mfa_enabled" // An MFA factor was enabled (v2.15.5+)
"auth.mfa_disabled" // An MFA factor was disabled
"auth.mfa_recovery_codes_generated" // Recovery codes were generated⚠️ auth.verification_requested payload changed in v2.16.0. It is now:
{
entity_id // The identifier being verified, e.g. an email address
entity_type // e.g. "email"
code_provider // Defaults to "token"
auth_identity_id
code // The verification code — was called `token` before v2.16.0
expires_at
metadata
}token, actor_type, provider, and provider_identity_id are gone. In subscribers, build the verification URL from code, and guard on entity_type (e.g. if (entity_type !== "email") return) instead of actor_type.
"invite.created" // Invite was created (for admin users)For a complete list of events, ask MedusaDocs for the specific module’s events.
interface SubscriberArgs<T> {
event: {
eventName: string // e.g., "order.placed"
data: T // Event payload (usually contains { id: string })
}
container: MedusaContainer // DI container
}⚠️ IMPORTANT: The data object typically only contains the entity ID. Retrieve the full entity data using services or query:
// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
// data.id contains the order ID
logger.info(`Handling order.placed event for order: ${data.id}`)
// Retrieve full order data with relations
const { data: orders } = await query.graph({
entity: "order",
fields: [
"id",
"email",
"total",
"customer.*",
"items.*",
"items.product.*",
],
filters: {
id: data.id,
},
})
const order = orders[0]
// Now you have the full order data
logger.info(`Order total: ${order.total}`)
logger.info(`Customer email: ${order.customer.email}`)
}
export const config: SubscriberConfig = {
event: "order.placed",
}export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productService = container.resolve("product")
// Retrieve product using service
const product = await productService.retrieveProduct(data.id, {
select: ["id", "title", "status"],
relations: ["variants"],
})
// Process product
}Emit custom events from workflows using the emitEventStep:
// src/workflows/create-review.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "@medusajs/medusa/core-flows"
const createReviewWorkflow = createWorkflow(
"create-review",
function (input: { product_id: string; rating: number }) {
// Create review step
const review = createReviewStep(input)
// Emit custom event
emitEventStep({
eventName: "review.created",
data: {
id: review.id,
product_id: input.product_id,
rating: input.rating,
},
})
return new WorkflowResponse({ review })
}
)
export default createReviewWorkflowThen create a subscriber for the custom event:
// src/subscribers/review-created.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function reviewCreatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string; product_id: string; rating: number }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
logger.info(`Review ${data.id} created for product ${data.product_id}`)
// If rating is low, notify support
if (data.rating <= 2) {
const notificationService = container.resolve("notification")
await notificationService.createNotifications({
to: "support@example.com",
template: "low-rating-alert",
channel: "email",
data: {
review_id: data.id,
product_id: data.product_id,
rating: data.rating,
},
})
}
}
export const config: SubscriberConfig = {
event: "review.created",
}export default async function mySubscriber({
event: { eventName, data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Handling ${eventName} for ${data.id}`)
try {
// Subscriber logic
logger.info(`Successfully handled ${eventName}`)
} catch (error) {
logger.error(`Failed to handle ${eventName}: ${error.message}`)
}
}Subscribers run asynchronously and don’t block the main flow. Log errors but don’t throw:
// ✅ GOOD: Catches errors and logs
export default async function mySubscriber({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
try {
// Subscriber logic that might fail
await sendEmail(data.id)
} catch (error) {
logger.error(`Failed to send email: ${error.message}`)
// Don't throw - subscriber completes gracefully
}
}Subscribers should perform quick operations. For long-running tasks, consider:
// ✅ GOOD: Quick operation
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const notificationService = container.resolve("notification")
// Quick: Queue email for sending
await notificationService.createNotifications({
to: "customer@example.com",
template: "order-confirmation",
channel: "email",
data: { order_id: data.id },
})
}If your subscriber needs to perform mutations, use workflows:
// ✅ GOOD: Uses workflow for mutations
import { syncProductWorkflow } from "../workflows/sync-product"
export default async function productCreatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
// Execute workflow to sync to external system
try {
await syncProductWorkflow(container).run({
input: { product_id: data.id },
})
logger.info(`Product ${data.id} synced successfully`)
} catch (error) {
logger.error(`Failed to sync product ${data.id}: ${error.message}`)
}
}Be careful when subscribing to events that trigger more events:
// ❌ BAD: Can cause infinite loop
export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productService = container.resolve("product")
// This triggers another product.updated event!
await productService.updateProducts({
id: data.id,
metadata: { last_updated: new Date() },
})
}
// ✅ GOOD: Add guard condition
export default async function productUpdatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const query = container.resolve("query")
// Retrieve product to check if we should update
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "metadata"],
filters: { id: data.id },
})
const product = products[0]
// Guard: Only update if not already processed
if (!product.metadata?.processed) {
const productService = container.resolve("product")
await productService.updateProducts({
id: data.id,
metadata: { processed: true },
})
}
}Subscribers might be called multiple times for the same event. Design them to handle this:
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
const myService = container.resolve("my-service")
// Check if we've already processed this order
const processed = await myService.isOrderProcessed(data.id)
if (processed) {
logger.info(`Order ${data.id} already processed, skipping`)
return
}
// Process order
await myService.processOrder(data.id)
// Mark as processed
await myService.markOrderAsProcessed(data.id)
}// src/subscribers/order-placed.ts
import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework"
export default async function sendOrderConfirmationEmail({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const logger = container.resolve("logger")
logger.info(`Sending order confirmation for order: ${data.id}`)
try {
const query = container.resolve("query")
// Retrieve full order data
const { data: orders } = await query.graph({
entity: "order",
fields: [
"id",
"display_id",
"email",
"total",
"currency_code",
"customer.first_name",
"customer.last_name",
"items.*",
"items.product.title",
"shipping_address.*",
],
filters: {
id: data.id,
},
})
if (!orders || orders.length === 0) {
logger.error(`Order ${data.id} not found`)
return
}
const order = orders[0]
// Send confirmation email
const notificationService = container.resolve("notification")
await notificationService.createNotifications({
to: order.email,
template: "order-confirmation",
channel: "email",
data: {
order_id: order.display_id,
customer_name: `${order.customer.first_name} ${order.customer.last_name}`,
items: order.items,
total: order.total,
currency: order.currency_code,
shipping_address: order.shipping_address,
},
})
logger.info(`Order confirmation email sent to ${order.email}`)
} catch (error) {
logger.error(
`Failed to send order confirmation for ${data.id}: ${error.message}`
)
}
}
export const config: SubscriberConfig = {
event: "order.placed",
}