Skip to content
Back to skills

Best Practices

ASecurity

Quick reference for Better Notify configuration, patterns, and common gotchas

  • 8 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 12, 2026
businesstypescriptgonodetestinggitapi

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 12, 2026

npx -y skills add ali-master/skills --skill best-practices --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Best Practices?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Best Practices
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ali-master-best-practices/badge)](https://www.skillsdirectory.com/skills/ali-master-best-practices)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: better-notify/best-practices
description: Quick reference for Better Notify configuration, patterns, and common gotchas
license: MIT
metadata:
  author: Ali Torki
  homepage: https://github.com/ali-master
  version: "1.0.0"
---

# Better Notify Quick Reference

**Always consult [better-notify.com/docs](https://better-notify.com/docs) for latest API.**

---

## Setup Workflow

1. Install: `npm install @betternotify/core @betternotify/email` (+ channel/transport packages)
2. Define channels and catalog in `lib/notify.ts`
3. Create client with transports in `lib/notify-client.ts`
4. Send: `mail.<route>.send({ to, input })`

---

## Core API

| Function                                               | Import               | Purpose                                         |
| ------------------------------------------------------ | -------------------- | ----------------------------------------------- |
| `createNotify({ channels })`                           | `@betternotify/core` | Root builder with channel map                   |
| `createClient({ catalog, transportsByChannel })`       | `@betternotify/core` | Type-safe send client                           |
| `createCatalog(map)`                                   | `@betternotify/core` | Standalone catalog (without builder)            |
| `defineChannel({ name, slots, validateArgs, render })` | `@betternotify/core` | Custom channel definition                       |
| `slot.resolver<T>()` / `slot.value<T>()`               | `@betternotify/core` | Template slot declarations                      |
| `consoleLogger({ level })`                             | `@betternotify/core` | Built-in console logger                         |
| `handlePromise(promise)`                               | `@betternotify/core` | Tuple-returning async wrapper `[error, result]` |

---

## Channel Slots

Each channel has typed slots set via the builder:

### Email (`@betternotify/email`)

| Slot                 | Type                                          | Required |
| -------------------- | --------------------------------------------- | -------- |
| `.input(schema)`     | Standard Schema (Zod, Valibot, ArkType)       | Yes      |
| `.subject(resolver)` | `string` or `(args) => string`                | Yes      |
| `.template(adapter)` | `TemplateAdapter` or `{ render }`             | Yes      |
| `.from(resolver)`    | `Address` or `(args) => Address`              | No       |
| `.replyTo(address)`  | `Address`                                     | No       |
| `.tags(tags)`        | `Record<string, string \| number \| boolean>` | No       |
| `.priority(level)`   | `'low' \| 'normal' \| 'high'`                 | No       |

**Send args:** `{ to, input, cc?, bcc?, replyTo?, from?, headers?, attachments? }`

### SMS (`@betternotify/sms`)

| Slot              | Type                           | Required |
| ----------------- | ------------------------------ | -------- |
| `.input(schema)`  | Standard Schema                | Yes      |
| `.body(resolver)` | `string` or `(args) => string` | Yes      |

**Send args:** `{ to: string, input }` (phone number)

### Push (`@betternotify/push`)

| Slot               | Type                           | Required |
| ------------------ | ------------------------------ | -------- |
| `.input(schema)`   | Standard Schema                | Yes      |
| `.title(resolver)` | `string` or `(args) => string` | Yes      |
| `.body(resolver)`  | `string` or `(args) => string` | Yes      |
| `.data(resolver)`  | `Record<string, unknown>`      | No       |
| `.badge(resolver)` | `number`                       | No       |

**Send args:** `{ to: string \| string[], input }` (device tokens)

### Slack (`@betternotify/slack`)

| Slot                | Type                           | Required |
| ------------------- | ------------------------------ | -------- |
| `.input(schema)`    | Standard Schema                | Yes      |
| `.text(resolver)`   | `string` or `(args) => string` | Yes      |
| `.blocks(resolver)` | `SlackBlock[]`                 | No       |

**Send args:** `{ input, to?: string, threadTs?: string }` (channel ID)

### Discord (`@betternotify/discord`)

| Slot                | Type                           | Required |
| ------------------- | ------------------------------ | -------- |
| `.input(schema)`    | Standard Schema                | Yes      |
| `.body(resolver)`   | `string` or `(args) => string` | Yes      |
| `.embeds(resolver)` | `DiscordEmbed[]`               | No       |
| `.username(value)`  | `string`                       | No       |
| `.avatarUrl(value)` | `string`                       | No       |

**Send args:** `{ input }`

### Telegram (`@betternotify/telegram`)

| Slot                    | Type                                   | Required |
| ----------------------- | -------------------------------------- | -------- |
| `.input(schema)`        | Standard Schema                        | Yes      |
| `.body(resolver)`       | `string` or `(args) => string`         | Yes      |
| `.parseMode(mode)`      | `'HTML' \| 'Markdown' \| 'MarkdownV2'` | No       |
| `.attachment(resolver)` | `TelegramAttachment`                   | No       |

**Send args:** `{ to: string \| number, input }` (chat ID)

---

## Subpath Imports

Import optional features from subpaths, **not** the root barrel:

```typescript
// Correct
import { withRateLimit } from '@betternotify/core/middlewares';
import { inMemoryRateLimitStore } from '@betternotify/core/stores';
import { consoleEventSink } from '@betternotify/core/sinks';
import { inMemoryTracer } from '@betternotify/core/tracers';

// Wrong — do not import these from '@betternotify/core'
```

| Subpath        | Exports                                                                                                                                                    |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/middlewares` | `withDryRun`, `withTagInject`, `withEventLogger`, `withRateLimit`, `withIdempotency`, `withTracing`, `createMiddleware`                                    |
| `/stores`      | `inMemorySuppressionList`, `inMemoryRateLimitStore`, `inMemoryIdempotencyStore`, `createSuppressionList`, `createRateLimitStore`, `createIdempotencyStore` |
| `/sinks`       | `inMemoryEventSink`, `consoleEventSink`, `createEventSink`                                                                                                 |
| `/tracers`     | `inMemoryTracer`                                                                                                                                           |
| `/transports`  | `createHttpClient`, `createTransport`, `multiTransport`, `mapTransport`                                                                                    |
| `/logger`      | `consoleLogger`, `fromPino`                                                                                                                                |
| `/plugins`     | `createPlugin`                                                                                                                                             |

---

## Middleware

Middleware mutates context or short-circuits the pipeline. Named with `with` prefix.

```typescript
const rpc = createNotify({ channels: { email: ch } })
  .use(withRateLimit({ store, key, max, window }))
  .use(withIdempotency({ store, key, ttl }))
  .use(withDryRun());
```

**Custom middleware:**

```typescript
import { createMiddleware } from '@betternotify/core/middlewares';

const withLogging = createMiddleware(async ({ next, route, messageId }) => {
  console.log(`Sending ${route} (${messageId})`);
  return next();
});
```

---

## Hooks

Hooks observe but don't mutate. Set on `createClient`:

```typescript
const mail = createClient({
  catalog,
  transportsByChannel: { email: transport },
  hooks: {
    onBeforeSend: ({ route, messageId, args }) => { ... },
    onExecute: ({ rendered }) => { ... },
    onAfterSend: ({ result, timing }) => { ... },
    onError: ({ error, phase }) => { ... },
  },
})
```

**Error phases:** `'validate' | 'middleware' | 'render' | 'send' | 'hook'`

**Rule:** If removing it would change whether the notification goes out, it must be middleware, not a hook.

---

## Client API

```typescript
// Single send
const result = await mail.welcome.send({ to, input })
// result: { messageId, data, envelope?, timing: { renderMs, sendMs } }

// Batch send
const batch = await mail.welcome.batch([{ to, input }, ...], { interval: 250 })
// batch: { okCount, errorCount, results: [{ status, result?, error? }] }

// Render only (no send)
const rendered = await mail.welcome.render(input)

// Cleanup
await mail.close()
```

---

## Address Types (Email)

```typescript
// String shorthand
{ to: 'user@example.com' }

// Object with name
{ to: { name: 'Alice', email: 'alice@example.com' } }

// Multiple recipients
{ to: ['alice@example.com', { name: 'Bob', email: 'bob@example.com' }] }

// From — both fields optional (merges with defaults)
{ from: { name: 'Support' } }         // uses default email
{ from: { email: 'no-reply@...' } }   // uses default name
```

---

## Error Classes

All errors subclass `NotifyRpcError` and are JSON-serializable.

| Error                          | When                                     |
| ------------------------------ | ---------------------------------------- |
| `NotifyRpcValidationError`     | Input fails schema validation            |
| `NotifyRpcRateLimitedError`    | Rate limit exceeded (has `retryAfterMs`) |
| `NotifyRpcNotImplementedError` | Feature not yet available                |
| `NotifyRpcProviderError`       | Transport delivery failure               |

---

## Multi-Transport Strategies

```typescript
import { multiTransport } from '@betternotify/email';

multiTransport({
  strategy: 'failover', // try next on failure
  transports: [{ transport: primary }, { transport: fallback }],
});
```

| Strategy      | Behavior                                   |
| ------------- | ------------------------------------------ |
| `failover`    | Try transports in order until one succeeds |
| `round-robin` | Rotate between transports                  |
| `random`      | Pick randomly                              |
| `race`        | Send via all, return first success         |
| `parallel`    | Send via all, wait for all                 |
| `mirrored`    | Send via all, return primary result        |

---

## Common Gotchas

1. **Import from subpaths** — `@betternotify/core/middlewares`, not `@betternotify/core`. Root barrel only exports core primitives.
2. **Standard Schema, not just Zod** — `.input()` accepts Zod, Valibot, or ArkType schemas. Don't assume Zod.
3. **Resolvers can be static or functions** — `.subject('Hello')` and `.subject(({ input }) => input.title)` are both valid.
4. **`from` merges per-field** — Per-email `from` and `defaults.from` shallow-merge. `{ from: { name: 'Support' } }` keeps the default email.
5. **Transport is dumb** — Transports receive fully-resolved messages. No validation, no rendering. That happens upstream in the pipeline.
6. **Middleware vs hooks** — If removing it changes whether the notification sends, it's middleware. Hooks only observe.
7. **Mock transports for testing** — Each channel package exports a mock: `mockTransport()` (email), `mockSmsTransport()` (SMS), etc. Check `.sent` or `.messages` array.
8. **Sub-catalogs flatten** — `{ transactional: rpc.catalog({ welcome: ... }) }` creates route `transactional.welcome`, accessed as `mail.transactional.welcome.send()`.
9. **ESM only** — Better Notify is ESM-only, requires Node >= 22.
10. **`handlePromise` over try/catch** — Use `const [err, result] = await handlePromise(promise)` for async error handling.

---

## Resources

- [Docs](https://better-notify.com/docs)
- [GitHub](https://github.com/better-notify/better-notify)
- [Examples](https://github.com/better-notify/better-notify/tree/main/examples)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.