npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devx-retailos/notifications

v0.0.1

Published

Generic notifications layer for retailOS. Pluggable NotificationChannelAdapter interface with built-in SMTP email, Limechat WhatsApp, and Slack webhook adapters. DB-stored templates, delivery logging, idempotency, and multi-instance-safe retry.

Readme

@devx-retailos/notifications

Medusa v2 plugin that provides a brand-agnostic notification system for retailOS. Supports multiple delivery channels (email, WhatsApp, Slack, custom), Handlebars-based templates with per-store/org/global specificity, automatic retry with SELECT FOR UPDATE SKIP LOCKED, and idempotent dispatch.

Installation

pnpm add @devx-retailos/notifications

Add to medusa-config.ts:

import { defineConfig } from "@medusajs/framework/utils"

export default defineConfig({
  plugins: [
    {
      resolve: "@devx-retailos/notifications",
      options: {
        channels: [
          {
            type: "email_smtp",
            config: {
              host: process.env.SMTP_HOST,
              port: 587,
              secure: false,
              from: process.env.SMTP_FROM,  // required — "Name <addr>" or bare address
              auth: {
                user: process.env.SMTP_USER,
                pass: process.env.SMTP_PASS,
              },
            },
          },
        ],
        retry: {
          cron: "*/15 * * * *",  // default
          max_attempts: 3,        // default
          batch_size: 50,         // default
        },
      },
    },
  ],
})

Plugin options

| Field | Type | Default | Description | |---|---|---|---| | channels | Array<{ type, config }> | [] | Channel adapters to activate. type must match a registered adapter. | | retry.cron | string | "*/15 * * * *" | Cron for the retry job. | | retry.max_attempts | number | 3 | Max delivery attempts before a dispatch is permanently failed. | | retry.batch_size | number | 50 | Rows claimed per retry cycle. |

Built-in adapters

| type | Description | Required config fields | |---|---|---| | email_smtp | Sends via SMTP (AWS SES, SendGrid, Postmark, self-hosted) | host, auth.user, auth.pass, from | | whatsapp_limechat | WhatsApp via Limechat API | api_token, account_id | | slack_webhook | Slack incoming webhook | webhook_url |

Custom adapter

Implement NotificationChannelAdapter<TConfig> and register it at boot:

import type { NotificationChannelAdapter } from "@devx-retailos/notifications"

const smsTwilioAdapter: NotificationChannelAdapter<TwilioConfig> = {
  type: "sms_twilio",
  description: "SMS via Twilio",
  capabilities: {
    supports_attachments: false,
    supports_subject: false,
    supports_html: false,
    is_synchronous: true,
  },
  validateConfig(config) {
    return TwilioConfigSchema.parse(config)
  },
  async send(config, input) {
    // call Twilio API
    return { status: "sent", provider_reference: sid }
  },
}

// Register in your Medusa subscriber or plugin boot:
const svc = container.resolve(NOTIFICATIONS_MODULE)
svc.registerAdapter(smsTwilioAdapter)

Templates

Templates are Handlebars strings stored in retailos_notification_template. The system resolves the most-specific template per channel:

store-scoped  >  org-scoped  >  global

Create a template

await svc.upsertTemplate({
  event_key: "order.confirmed",
  channel: "email_smtp",
  name: "Order confirmation email",
  subject_template: "Order {{order_id}} confirmed",
  body_template: `
    <p>Hi {{customer_name}},</p>
    <p>Your order <strong>{{order_id}}</strong> has been confirmed.</p>
    {{#if tracking_url}}<p><a href="{{tracking_url}}">Track your order</a></p>{{/if}}
    <p>Total: {{formatCurrency total 'INR'}}</p>
  `,
  // Scope to a specific store (omit for global)
  // store_id: "store_abc",
  // organization_id: "org_abc",
})

Built-in Handlebars helpers

| Helper | Usage | Output | |---|---|---| | formatCurrency | {{formatCurrency amount 'INR'}} | ₹99.99 (divides by 100) | | formatDate | {{formatDate date 'en-IN'}} | Locale-formatted date string |

Variables are HTML-escaped by default. Use {{{triple_braces}}} for trusted HTML.

Service API

import { NOTIFICATIONS_MODULE } from "@devx-retailos/notifications"
import type { NotificationsModuleService } from "@devx-retailos/notifications"

const svc = container.resolve<NotificationsModuleService>(NOTIFICATIONS_MODULE)

svc.dispatchEvent(eventKey, data, context)

Resolve templates for an event, render them, and send. The idempotency key is computed as sha256(eventKey:referenceId:channel:recipient) — duplicate calls for the same event + entity are safe.

await svc.dispatchEvent(
  "order.confirmed",
  {
    order_id: "ORD-001",
    customer_name: "Alice",
    customer_email: "[email protected]",
    total: 9999, // paise
  },
  {
    referenceId: "ord_001",        // entity whose event this is
    organizationId: "org_abc",     // narrows template scope
    storeId: "store_xyz",          // narrows template scope further
    recipient: "[email protected]",// overrides customer_email/phone from data
    channels: ["email_smtp"],      // optional; omit to dispatch all active channels
  }
)

svc.send(input)

Explicit single-dispatch. Creates a retailos_notification_dispatch row, sends, and updates status.

await svc.send({
  channel: "slack_webhook",
  recipient: "#ops-alerts",
  body: "New order ORD-001 placed.",
  event_key: "order.new",         // optional, defaults to "explicit_send"
  idempotency_key: "slack_ord_001",
  dedup_window_seconds: 300,      // skip if same key sent within 5 min
})

svc.upsertTemplate(input)

Create (no id) or update (with id) a template. Validates Handlebars syntax at write time.

svc.listTemplates(filters?)

const templates = await svc.listTemplates({
  event_key: "order.confirmed",
  channel: "email_smtp",
  is_active: true,
})

svc.listDeliveries(filters?)

const { dispatches, total } = await svc.listDeliveries({
  entity_type: "order",
  entity_id: "ord_001",
  status: "failed",
  limit: 20,
  offset: 0,
})

svc.listAdapters()

Returns all registered adapters with is_configured flag.

svc.registerAdapter(adapter)

Register a custom channel adapter at runtime.

Dispatch lifecycle

pending → processing (locked_until = now + 10 min) → sent | failed | skipped
  • locked_until prevents double-processing in multi-instance deploys.
  • The retry job (*/15 * * * *) reclaims failed rows with remaining attempts, stale processing rows (lock expired), and long-pending rows.
  • A skipped status is available for business-logic skips (no recipient, suppressed, etc.).

Scheduled jobs

| Job | Schedule | What it does | |---|---|---| | retry-failed-notifications | every 15 min | Claims and retries failed/stale dispatches using SELECT FOR UPDATE SKIP LOCKED | | cleanup-old-notifications | 02:00 daily | Deletes sent rows older than 90 days, failed/skipped older than 30 days |

Admin API routes

All routes are under /admin/retailos/notifications and require authentication.

| Method | Path | Description | |---|---|---| | GET | /templates | List templates (filterable by event_key, channel, is_active, org/store) | | POST | /templates | Create a new template | | PUT | /templates/:id | Update an existing template | | GET | /deliveries | List dispatch history (filterable by entity, status, channel, date range) |

Permissions

Register these keys via syncAllPermissions:

| Key | Description | |---|---| | notification.templates.read | View templates | | notification.templates.write | Create or update templates | | notification.deliveries.read | View delivery history | | notification.send | Trigger explicit sends via admin API |

import { NOTIFICATION_PERMISSIONS } from "@devx-retailos/notifications"
import { syncAllPermissions } from "@devx-retailos/core"

await syncAllPermissions(rbac, NOTIFICATION_PERMISSIONS)

Database tables

| Table | Description | |---|---| | retailos_notification_template | Template definitions per event/channel/scope | | retailos_notification_dispatch | Per-send audit log with status and retry state |

Run migrations after installing (replace <your-backend> with your project filter):

pnpm --filter <your-backend> db:migrate

Testing

pnpm --filter @devx-retailos/notifications test
pnpm --filter @devx-retailos/notifications typecheck

Tests are colocated at src/modules/notifications/__tests__/. The service layer is tested with a mocked MedusaService base — no real DB or Medusa container required.