@venturekit/notify
v0.0.21
Published
Transactional notifications for VentureKit — email (SES), WhatsApp (Meta Cloud), with a Postgres outbox + cron dispatcher.
Downloads
4,863
Maintainers
Readme
@venturekit/notify
Transactional notifications for VentureKit applications — email (AWS SESv2), WhatsApp (Meta Cloud API), with SMS / push as placeholders. Backed by a Postgres outbox + auto-provisioned cron dispatcher Lambda.
Pre-release — API may change before 1.0.
What you get
Declare one notify intent in vk.config.ts and vk deploy provisions:
- SES email identity (single-address or whole-domain, with DKIM signing when domain-based)
- SES configuration set with bounce / complaint / delivery / open / click events streaming into an SNS topic
- SNS bounce-handler Lambda that records every event in
notification_event_logand auto-suppresses hard-bounced addresses - Postgres outbox schema (
notifications,notification_preferences,notification_event_log) shipped with the package and merged into the project's migration runner - EventBridge cron + dispatcher Lambda that drains the outbox once per minute (configurable) and calls each row's provider
- Secrets Manager secret for the Meta WhatsApp API token (operator pastes the value after first deploy)
- IAM grants (
ses:SendEmail,ses:SendRawEmail,secretsmanager:GetSecretValue) scoped to the resources above on the shared Lambda execution role
Application code only touches notify.enqueue({...}). Retry, preference filtering, address suppression, bounce processing, and template rendering all happen behind the scenes.
Quick start
1. Declare the intent
// vk.config.ts
import { defineVenture } from '@venturekit/infra';
export default defineVenture({
base,
security,
databases: [{ id: 'main', type: 'postgres', name: 'app' }],
notify: [
{
id: 'main',
channels: ['email', 'whatsapp'],
defaultFrom: '[email protected]',
domain: 'app.com', // optional: SES verifies the whole domain
hostedZoneId: 'Z123…', // optional: auto-publish DKIM CNAMEs in Route53
whatsapp: {
phoneNumberId: '105954992345678', // from Meta Business Suite
wabaId: '102290129340398', // optional: only for managing templates
},
},
],
envs: { dev, prod },
});2. Build the runtime client
// src/lib/notify.ts
import {
createNotifyClient,
createPostgresNotifyStore,
createSesEmailProvider,
createMetaWhatsAppProvider,
createSecretsTokenResolver,
} from '@venturekit/notify';
import { findUserById } from '@/db/repos/users';
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!,
// Only needed for the template-management APIs below.
wabaId: process.env.VENTURE_NOTIFY_WHATSAPP_WABA_ID,
tokenResolver: createSecretsTokenResolver({
secretArn: process.env.VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET!,
}),
}),
],
// Map user ids -> channel-specific addresses. Phase 1 expects the
// consumer to own this lookup; future phases may add a default that
// reads from a user table convention.
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 them at module load so
// the dispatcher's first invocation already sees them.
notify.registerTemplate({
key: 'application_registered',
channel: 'email',
category: 'transactional_application',
render: ({ groupName }: { groupName: string }) => ({
subject: `Inscription confirmée: ${groupName}`,
text: `Votre inscription au groupe ${groupName} est confirmée.`,
html: `<p>Votre inscription au groupe <b>${groupName}</b> est confirmée.</p>`,
}),
});VentureKit's auto-provisioned dispatcher imports this module via VENTURE_NOTIFY_CLIENT_MODULE (default ./lib/notify.js). Override the env var per intent if your project uses a different path.
3. Send
// src/routes/applications/post.ts
import { notify } from '@/lib/notify';
await notify.enqueue({
recipientUserId: ctx.user.id,
channel: 'email',
templateKey: 'application_registered',
payload: { groupName: 'Île-de-France #4' },
relatedKind: 'application',
relatedId: applicationId,
});The handler returns synchronously after the row hits the outbox. The dispatcher cron picks it up on the next tick (≤ 1 minute by default) and calls SES.
Architecture
┌────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Route handler │ │ Outbox (Postgres) │ │ Dispatcher cron │
│ notify.enqueue() │───►│ notifications │◄───│ every 1 minute │
└────────────────────┘ │ + preferences │ │ drain pending │
│ + event_log │ │ call provider │
└──────────────────────┘ │ mark sent/fail │
└────┬─────────────┘
│
┌────────────────────────────────────────────┴─────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ AWS SESv2 │ │ Meta Graph API │
│ SendEmail │ │ /messages │
└──────────┬───────────┘ └──────────────────────┘
│
▼
┌──────────────────────┐
│ SES events SNS │
│ bounce / complaint │──────► Bounce handler Lambda ──► event_log
└──────────────────────┘ │
▼
Address suppressed
in future sendsThree persistent failure modes are recorded distinctly:
| Status | Meaning | Retried? |
| --- | --- | --- |
| pending | Awaiting next cron tick. | Yes — claimed by claimPending(). |
| sent | Provider accepted delivery. | No. May still flip to bounced. |
| failed | Provider rejected (hard error) or transient retries exhausted. | Manually via admin retry. |
| bounced | Bounce / complaint event arrived after sent. | Address auto-suppressed. |
| cancelled | Suppressed at enqueue (opt-out, address suppression). | No. |
Channels
| Channel | Phase 1 provider | Real impl? |
| --- | --- | --- |
| email | AWS SESv2 (@aws-sdk/client-sesv2, lazy-loaded) | ✅ |
| whatsapp | Meta Cloud API (Graph /messages, fetch) | ✅ |
| sms | noop-sms placeholder — logs and returns success | ❌ — register a real provider in your createNotifyClient({ providers }) array. |
| push | noop-push placeholder | ❌ — same as above. |
The dispatcher uses whichever provider is registered last for a given channel — wire your own SNS / Twilio / Pinpoint provider by passing it in the providers array; it overrides the noop.
Outbox tracking
Phase 1 persistence is always Postgres. NotifyIntent.tracking exists in the type for forward-compatibility but only 'postgres' is implemented. Auto-applied migrations create:
notifications— outbox / audit lognotification_preferences— per-(user, channel, category) opt-innotification_event_log— bounce / complaint / delivery / open / click events
Migrations live under node_modules/@venturekit/notify/migrations/vk_notify_*.sql. The infra package merges them into the project's CodeBuild migration runner asset on every vk deploy. Filename collisions with project-shipped migrations throw at synth time — VentureKit packages prefix their files with vk_<package>_ to avoid that hazard.
Templates
Phase 1 keeps templates in code. Each template is a typed (payload) -> { subject?, text, html?, whatsapp? } function registered at boot:
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>`,
}),
});Templates without a category bypass the per-user opt-out check — useful for password resets, security alerts, etc., that the user can never opt out of.
WhatsApp template messages need Meta-pre-approved templates and use the RenderedMessage.whatsapp block:
notify.registerTemplate({
key: 'application_received',
channel: 'whatsapp',
category: 'transactional_application',
render: ({ name, groupName }: { name: string; groupName: string }) => ({
text: `Hi ${name}, your application to ${groupName} is in.`,
whatsapp: {
metaTemplateName: 'application_received_v1',
languageCode: 'fr',
components: [
{
type: 'body',
parameters: [
{ type: 'text', text: name },
{ type: 'text', text: groupName },
],
},
],
},
}),
});Phase 2 will introduce DB-backed templates with admin UI editing — see the project roadmap.
Managing WhatsApp templates on Meta
The metaTemplateName above has to exist and be approved on Meta's side before you can send it. You can create it by hand in WhatsApp Manager, or from code — createMetaWhatsAppProvider returns a MetaWhatsAppProvider with four extra methods for the Business Management API.
These are not used by the dispatcher; call them from a migration, a CLI task, or an admin route. All four need a wabaId except updateTemplate, which addresses the template node directly.
import { createMetaWhatsAppProvider, createSecretsTokenResolver } from '@venturekit/notify';
const wa = createMetaWhatsAppProvider({
phoneNumberId: process.env.VENTURE_NOTIFY_WHATSAPP_PHONE_ID!,
wabaId: process.env.VENTURE_NOTIFY_WHATSAPP_WABA_ID,
tokenResolver: createSecretsTokenResolver({
secretArn: process.env.VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET!,
}),
});
// 1. Submit for review. Names are lowercase + digits + underscores only.
const created = await wa.createTemplate({
name: 'application_received_v1',
language: 'fr',
category: 'UTILITY',
// Let Meta re-categorise rather than reject outright. Changes billing,
// so check `created.category` for what it actually assigned.
allowCategoryChange: true,
components: [
{ type: 'BODY', text: 'Bonjour {{1}}, votre dossier {{2}} est reçu.',
example: { body_text: [['Ana', 'Île-de-France #4']] } },
],
});
// -> { id, name, status: 'PENDING', category }
// 2. Poll until Meta finishes reviewing (also delivered as a
// `message_template_status_update` webhook).
const status = await wa.getTemplateStatus('application_received_v1', 'fr');
if (status.status === 'REJECTED') console.error(status.rejectedReason);
// 3. Edits re-open review, so the response only confirms acceptance —
// poll `getTemplateStatus` again for the outcome.
await wa.updateTemplate({
templateId: created.id,
components: [{ type: 'BODY', text: 'Bonjour {{1}}, dossier {{2}} confirmé.' }],
});
// 4. Delete one language variant, or every language sharing the name.
await wa.deleteTemplate({ name: 'application_received_v1', templateId: created.id });
await wa.deleteTemplate({ name: 'application_received_v1' });A few Meta quirks the provider papers over, worth knowing about:
statusandcategoryare open string unions. Meta adds values without notice (PAUSED,LIMIT_EXCEEDED, legacyTRANSACTIONAL, …), so don't assume aswitchover them is exhaustive.- Reads are paginated and field-gated.
getTemplateStatusasks forrejected_reasonexplicitly (it is absent from Meta's default field set) and followspaging.nextup to 10 pages, because one name can exist once per language. - Failures split by retryability.
ProviderSendErrormeans Meta was unreachable, rate-limited or broken — retrying the identical call may work.ProviderHardErrormeans the request or the configuration is wrong.
Admin / inbox helpers
@venturekit/notify/admin exposes route-helper functions consumers wire into their own handlers (no automatic mounting — pick your own auth/scope conventions):
// src/routes/admin/notifications/list/get.ts
import { handler } from '@venturekit/runtime';
import { adminListNotifications } from '@venturekit/notify/admin';
import { notify } from '@/lib/notify';
export const main = handler(async (ctx) => {
ctx.requireScope('admin.notifications.read');
const url = new URL(ctx.request.url);
return adminListNotifications(notify, {
limit: Number(url.searchParams.get('limit') ?? 50),
offset: Number(url.searchParams.get('offset') ?? 0),
status: url.searchParams.get('status') as never,
search: url.searchParams.get('q') ?? undefined,
});
});Available helpers:
adminListNotifications(client, options)— paginated list with status / channel / template / search filtersadminGetNotification(client, id)adminSendNotification(client, input)— equivalent toclient.enqueue(...), separate name for audit loggingadminRetryNotification(client, id)— flipsfailed/bouncedback topendingadminCancelNotification(client, id)— cancels apendingrow before dispatchadminClearSuppression(client, channel, address)— lifts a bounce / complaint suppressionlistUserPreferences(client, userId)/upsertUserPreference(client, ...)— per-user opt-in managementlistNotificationsForUser(client, userId, options)— buyer-portal inbox view
Local dev (vk dev)
Declaring a notify intent with the email channel adds a MailHog service to ~/.vk/docker-compose.yml:
- SMTP at
localhost:1025 - Web inbox at
http://localhost:8025
VK's local-mode runtime injects VENTURE_NOTIFY_DRIVER=local so the email provider falls through to the noop / log adapter — Phase 1 doesn't auto-route through MailHog yet (planned for Phase 2). Until then operators can spin a custom email provider against MailHog by passing endpoint: 'http://localhost:1025' in createSesEmailProvider (the SES SDK lets you point at any SMTP, MailHog accepts).
Environment variables
The infra package injects these on every Lambda when a notify intent is declared:
| Variable | Source |
| --- | --- |
| VENTURE_NOTIFY_FROM | First notify intent's defaultFrom |
| VENTURE_NOTIFY_CONFIG_SET | First notify intent's SES configuration set name |
| VENTURE_NOTIFY_EVENTS_TOPIC_ARN | First notify intent's SES events SNS topic ARN |
| VENTURE_NOTIFY_WHATSAPP_PHONE_ID | First notify intent's whatsapp.phoneNumberId |
| VENTURE_NOTIFY_WHATSAPP_WABA_ID | First notify intent's whatsapp.wabaId (optional; needed by createTemplate / getTemplateStatus / deleteTemplate) |
| VENTURE_NOTIFY_WHATSAPP_API_VERSION | First notify intent's whatsapp.apiVersion |
| VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET | Secrets Manager ARN for the WhatsApp token |
| NOTIFY_<ID>_FROM / NOTIFY_<ID>_CONFIG_SET / NOTIFY_<ID>_EVENTS_TOPIC_ARN | Per-id overrides for projects with multiple intents |
| VENTURE_NOTIFY_BATCH_SIZE | Dispatcher cron — overrides default 50 |
| VENTURE_NOTIFY_CLIENT_MODULE | Path to the consumer's notify-client module (default ./lib/notify.js) |
Migrating from @venturekit-pro/comms
The 0.x cut of @venturekit-pro/comms shipped email + push + SMS + WhatsApp + chat in one package. The split:
- Notifications (this package, free tier): all four channels (email / WhatsApp / SMS / push), templates, preferences, outbox, dispatcher.
- Chat (
@venturekit-pro/chat, Pro): rooms, participants, messages, presence — unchanged API.
Migrate with one install:
pnpm add @venturekit/notify @venturekit-pro/chat
pnpm remove @venturekit-pro/commsThen update imports — the chat manager API is unchanged, the notify API is intent-driven (declare in vk.config.ts, get auto-provisioned SES + outbox + cron).
License
Apache-2.0
