Skip to content

@venturekit/notify

@venturekit/notify provides transactional messaging — email (AWS SESv2), WhatsApp (Meta Cloud API), with SMS and push as pluggable placeholders. It is intent-driven: declare a notify intent in vk.config.ts and vk deploy auto-provisions an SES identity + configuration set, a bounce/complaint handler, a Postgres outbox, and an EventBridge cron dispatcher. Application code only calls notify.enqueue(...).

Terminal window
npm install @venturekit/notify
vk.config.ts
import { defineVenture } from '@venturekit/infra';
export default defineVenture({
base,
databases: [{ id: 'main', type: 'postgres', name: 'app' }],
notify: [
{
id: 'main',
channels: ['email', 'whatsapp'],
defaultFrom: 'no-reply@app.com',
domain: 'app.com', // optional: SES verifies the whole domain (DKIM)
hostedZoneId: 'Z123ABC', // optional: auto-publish DKIM CNAMEs in Route 53
whatsapp: { phoneNumberId: '105954992345678' },
},
],
envs: { dev, prod },
});

See Infrastructure Intents for the full intent shape.

src/lib/notify.ts
import {
createNotifyClient,
createPostgresNotifyStore,
createSesEmailProvider,
createMetaWhatsAppProvider,
createSecretsTokenResolver,
} from '@venturekit/notify';
export const notify = createNotifyClient({
store: createPostgresNotifyStore(),
defaultFrom: process.env.VENTURE_NOTIFY_FROM,
providers: [
createSesEmailProvider({
region: process.env.AWS_REGION!,
configurationSetName: process.env.VENTURE_NOTIFY_CONFIG_SET,
}),
createMetaWhatsAppProvider({
phoneNumberId: process.env.VENTURE_NOTIFY_WHATSAPP_PHONE_ID!,
tokenResolver: createSecretsTokenResolver({
secretArn: process.env.VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET!,
}),
}),
],
// Map user ids → channel-specific addresses (consumer-owned in Phase 1).
addressResolver: async (userId, channel) => {
const user = await findUserById(userId);
if (channel === 'email') return user?.email ?? null;
if (channel === 'whatsapp') return user?.whatsappNumber ?? null;
return null;
},
});
// Templates live in code in Phase 1 — register at module load.
notify.registerTemplate<{ name: string }>({
key: 'welcome',
channel: 'email',
category: 'transactional_account',
render: ({ name }) => ({
subject: `Welcome, ${name}`,
text: `Hi ${name}, welcome aboard.`,
html: `<p>Hi <b>${name}</b>, welcome aboard.</p>`,
}),
});

The auto-provisioned dispatcher imports this module via VENTURE_NOTIFY_CLIENT_MODULE (default ./lib/notify.js).

import { notify } from '@/lib/notify';
await notify.enqueue({
recipientUserId: ctx.user!.id,
channel: 'email',
templateKey: 'welcome',
payload: { name: 'Alice' },
relatedKind: 'account',
relatedId: accountId,
});

enqueue() returns synchronously after the row lands in the outbox. The dispatcher cron drains it on the next tick (≤ 1 minute by default) and calls the provider.

ChannelPhase 1 providerReal impl?
emailAWS SESv2 (lazy-loaded)Yes
whatsappMeta Cloud API (Graph /messages)Yes
smsnoop placeholderNo — register your own provider
pushnoop placeholderNo — register your own provider

Persistence is Postgres. Auto-applied migrations (vk migrate / vk deploy) create notifications, notification_preferences, and notification_event_log. Each notification moves through these statuses:

StatusMeaningRetried?
pendingAwaiting the next cron tickYes
sentProvider accepted deliveryNo (may flip to bounced)
failedHard error or retries exhaustedManually via admin retry
bouncedBounce/complaint event arrived after sentAddress auto-suppressed
cancelledSuppressed at enqueue (opt-out/suppression)No
SubpathDescription
@venturekit/notifyClient, providers, templates, store, types
@venturekit/notify/adminAdmin / inbox route helpers (adminListNotifications, adminRetryNotification, …)
@venturekit/notify/dispatcherdispatchOnce, makeDispatchHandler (cron Lambda)
@venturekit/notify/bounce-handlermakeBounceHandler (SNS bounce/complaint Lambda)
@venturekit/notify/providers/email-sesSES email provider
@venturekit/notify/providers/whatsapp-metaMeta WhatsApp provider (send + template management)
@venturekit/notify/providers/whatsapp-meta-webhookInbound Meta webhooks — createMetaWhatsAppWebhookHandler, signature verification, event parsing
@venturekit/notify/providers/whatsapp-meta-languagetoMetaLanguage / fromMetaLanguage locale ↔ Meta code bridge
@venturekit/notify/providers/push-expoExpo push provider (createExpoPushProvider, sendExpoPush, receipts)
@venturekit/notify/policyChannel fallback chains, quiet hours, operator-owned string templates (with escapeHtml), locale resolution, frequency caps (checkFrequencyCap — “3 marketing e-mails per member per week”)
@venturekit/notify/inboxThe in-app notification centre: createInboxNotification (stored copy + messageKey / messageParams for reader-locale rendering, groupKey collapsing), listInbox, unreadInboxCount, markInboxRead, pruneInbox, and runInboxDigest — one e-mail per user batching their unread rows through your notify client. Own tables (vk_notify_003, vk_notify_004); the delivery outbox is untouched
@venturekit/notify/store/postgresPostgres outbox store
@venturekit/notify/migrationsOutbox migration SQL (auto-discovered by vk migrate / merged by @venturekit/infra)
@venturekit/notify/runtime/dispatcher-stub, /runtime/bounce-handler-stubThe Lambda entry points the infra stack bundles for the dispatcher cron and bounce handler

The webhook, language, policy and Expo exports are also re-exported from the package root; the inbox is only reachable through its subpath. See the API reference and Inbox.

import { createInboxNotification, listInbox, unreadInboxCount } from '@venturekit/notify/inbox';
import { query } from '@venturekit/data';
await createInboxNotification(query, {
userId: member.id, tenantId: ctx.tenant!.id, type: 'comment.reply',
title: `${author.name} replied to your comment`, // sender-locale fallback
messageKey: 'inbox.comment.reply', messageParams: { author: author.name }, // reader-locale rendering
referenceKind: 'post', referenceId: post.id,
groupKey: `post:${post.id}:replies`, // repeated replies collapse into one entry
});
const [items, unread] = await Promise.all([
listInbox(query, { userId: ctx.user!.id, limit: 20 }),
unreadInboxCount(query, { userId: ctx.user!.id }),
]);

Declaring a notify intent with the email channel adds a MailHog service to your local Docker Compose (SMTP on localhost:1025, web inbox at http://localhost:8025).

  • @venturekit/core — required
  • @venturekit/data — optional peer (Postgres outbox store)
  • @venturekit/runtime — optional peer (dispatcher / handler wiring)
  • @aws-sdk/client-sesv2 — optional peer (SES email)
  • @aws-sdk/client-secrets-manager — optional peer (WhatsApp token)