@venturekit/notify
v0.0.48
Published
Transactional notifications for VentureKit — email (SES), WhatsApp (Meta Cloud), with a Postgres outbox + cron dispatcher.
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 — plus the in-app inbox, the push device registry and stored templates, each its own table - 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.
A message that must also reach a second mailbox — the billing address on every invoice, an account manager on a welcome — takes cc: ['[email protected]']: one send, visible to everyone on it (SES Cc:), never a second row the recipient would not see. E-mail only; a copy address on the suppression list is dropped from the row rather than failing the message.
A message that must go out once, however often the code sending it runs — a weekly digest its cron retries, a webhook delivered twice, a request replayed — takes a dedupeKey:
const out = await notify.enqueue({
address: member.email, channel: 'email', templateKey: 'weekly_digest', payload,
dedupeKey: `weekly_digest:${tenantId}:${weekStart}:${member.email.toLowerCase()}`,
});
if (out.duplicate) { /* sent (or cancelled) before: nothing was written now; out.id is that row */ }The key is held by whatever row was written for it — sent, pending, or cancelled by a gate — and a repeat writes nothing, asks no budget, and gets that row back. Two enqueues at once cannot both write: the unique index on vk_notifications.dedupe_key (vk_notify_006) decides, without an SQL error, so a caller's transaction carries on. The key is global to the outbox — put the tenant in it. A push fanned out to a person's phones keys each phone's row ${dedupeKey}#${token}, so a repeat reaches only a phone registered since. A custom NotifyStore dedupes by throwing DuplicateNotificationError from createNotification, and may implement findByDedupeKey to answer repeats before anything else runs.
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 | FCM HTTP v1 (…/providers/push-fcm, fetch), APNs (…/providers/push-apns, node:http2), Expo (…/providers/push-expo, fetch) — or noop-push | ✅ — with the device registry below. |
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 },
],
},
],
},
}),
});Code templates are for the product's own messages. A message a customer words — a tenant rewording its arrival notice, a school translating a reminder — is a stored template, kept as data.
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.
Sending a template, and paying for it
A business-initiated message (to a number that has not written in 24 h) must be an approved
template, and its parameters have rules of their own. @venturekit/notify/providers/whatsapp-meta-template:
import { metaTemplateBody, metaCategoryCost } from '@venturekit/notify/providers/whatsapp-meta-template';
message: {
subject: '', text: `${title}\n${body}`,
whatsapp: { metaTemplateName: 'msb_notice', languageCode: 'fr', components: metaTemplateBody([title, body], [60, 900]) },
}metaTemplateParameter makes a text one line (Meta refuses line breaks, tabs and runs of
spaces in a parameter) and cuts it under the cap; normalizeMetaCategory reads the legacy
names as Meta bills them; metaCategoryCost weighs a category on a quota.
A quota on the channel goes on the client as a budget, asked after every other gate:
createNotifyClient({
store, providers,
budget: {
channels: ['whatsapp'],
reserve: async ({ category, recipientUserId }) => ({ allowed: await quota.take(recipientUserId, metaCategoryCost(category)) }),
},
});A refused message is recorded cancelled and enqueue answers suppressionReason:
'budget-exhausted'; sendNow calls the reservation's release() when the provider
refuses. @venturekit-pro/billing's createMeter is a ready-made budget, with credits that
roll over, exempt sends and automatic top-ups.
Receiving Meta webhooks
Meta pushes a webhook when a template's approval status changes (message_template_status_update) and when it re-categorises a template after approval (template_category_update — e.g. UTILITY → MARKETING, which changes what each message is billed as). The package ships the wire-format half of an endpoint; you own the route, the secrets and whatever happens to the events.
Provisioning the secrets
Declare whatsapp.webhooks: true on the notify intent:
notify: [{
id: 'transactional',
channels: ['email', 'whatsapp'],
defaultFrom: '[email protected]',
whatsapp: {
phoneNumberId: '…',
wabaId: '…',
webhooks: true, // ← provisions the two webhook secrets
},
}],The messaging stack creates two Secrets Manager secrets (operator-paste-in, same pattern as the token secret) and the app stack injects their ARNs as VENTURE_NOTIFY_WHATSAPP_APP_SECRET and VENTURE_NOTIFY_WHATSAPP_VERIFY_TOKEN_SECRET. After first deploy, paste the Meta App Secret (App Dashboard → Settings → Basic) and your chosen verify token into the two secrets via aws secretsmanager put-secret-value; type the same verify token into the App Dashboard when registering the callback URL.
Building the handler
createMetaWhatsAppWebhookHandler() resolves both secrets automatically from those env vars (lazy, cached per cold start), so consumer route code passes zero credential arguments:
import { createMetaWhatsAppWebhookHandler, fromMetaLanguage } from '@venturekit/notify';
const wa = createMetaWhatsAppWebhookHandler(); // zero-arg in prod
// GET — one-time handshake when the callback URL is saved in the App Dashboard.
if (method === 'GET') {
const challenge = await wa.handleVerification(query);
return { statusCode: 200, headers: { 'content-type': 'text/plain' }, body: challenge };
}
// POST — verify against the *raw* body, then parse the same bytes.
await wa.verifySignature(rawBody, headers['x-hub-signature-256']);
for (const event of wa.parseEvents(rawBody)) {
const locale = 'language' in event ? fromMetaLanguage(event.language) : undefined; // 'en-US' -> 'en'
switch (event.type) {
case 'template_category_changed': // previousCategory -> newCategory, in effect now
case 'template_category_change_scheduled': // currentCategory -> correctCategory at scheduledAtMs
case 'template_status_updated': // status, reason?, category?, disabledAtMs?, …
case 'unknown': // any other field (`messages`, `account_update`, …), value passed through
}
}
return { statusCode: 200 }; // Meta retries anything but a 2xxOverride the resolvers for tests or when the secrets are not provisioned by VentureKit:
const wa = createMetaWhatsAppWebhookHandler({
appSecretResolver: async () => process.env.META_APP_SECRET!,
verifyTokenResolver: async () => process.env.META_VERIFY_TOKEN!,
});The three error classes (MetaWebhookSignatureError, MetaWebhookVerificationError, MetaWebhookPayloadError) map to a 403/401; never process a payload whose signature failed. The pure functions (verifyMetaWebhookSignature, handleMetaWebhookVerification, parseMetaWebhookEvents) are also exported for consumers that prefer to manage credential resolution themselves. toMetaLanguage('en') → 'en_US' is the forward mapping for createTemplate / languageCode; pass a custom map ({ en: 'en_GB', pt: 'pt_PT' }) to change the regional defaults.
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
In-app inbox (the bell)
@venturekit/notify/inbox is the notification centre: rows a user reads and
dismisses, in their own table (vk_inbox_notifications), independent of the
delivery outbox above. Rows carry a stored title/body plus
messageKey + messageParams so the client can re-render in the reader's
locale; groupKey collapses repeats ("3 new comments") into one entry.
import { createInboxNotification, listInbox, unreadInboxCount, markInboxRead, markAllInboxRead } from '@venturekit/notify/inbox';
// fan-out, in the same transaction as the change
await createInboxNotification(tx.query, {
userId: post.authorId, type: 'post_comment',
title: `${actor.name} commented on your post`,
messageKey: 'post_comment', messageParams: { name: actor.name },
referenceKind: 'post', referenceId: post.id,
});
// GET /notifications?before=… GET /activity POST /notifications/read
const page = await listInbox(query, { userId, unreadOnly: false, limit: 20, before });
const unread = await unreadInboxCount(query, { userId, types: enabledTypes });
await markInboxRead(query, { userId, ids }); // always user-scoped
await markAllInboxRead(query, { userId, before: seenAt });Once, not collapsed. groupKey on createInboxNotification resurfaces the
row (refreshed, unread again). For a fact that must reach the user once however
often it is raised — a retried request, an overlapping cron pass — use
createInboxNotificationOnce: an existing row for the key is left exactly as it is
and null comes back, so the caller also knows whether to send the e-mail that goes
with it.
const row = await createInboxNotificationOnce(tx.query, {
userId, type: 'invoice_overdue', title: `Invoice ${n} is overdue`,
referenceKind: 'invoice', referenceId: invoice.id,
groupKey: `invoice_overdue:${invoice.id}:${invoice.dueAt}`,
});
if (row) await notify.enqueue({ /* the e-mail, once */ });Erasure. The table has no foreign key to your records; when one is deleted,
deleteInboxByReference(tx.query, { referenceKind: 'lead', referenceId }) removes
every user's rows about it, in the same transaction.
pruneInbox(query, { olderThanDays: 90 }) is the retention sweep for a cron.
Per-type preferences and realtime badge pushes stay in the app — the module
owns the rows, not the policy.
Digests. Real-time in-app, e-mail only when the user was away:
// src/crons/inbox-digest.ts (schedules: rate 1 hour)
export const main = taskHandler(() =>
runInboxDigest(query, {
enqueue: (input) => notify.enqueue(input), // your client → templates, preferences, suppression apply
templateKey: 'inbox_digest',
minAgeMinutes: 30, maxPerUserPerDay: 1,
shouldSend: (userId) => prefs.emailDigestsEnabled(userId),
}),
);One e-mail per user with their unread rows since the last digest; a per-user
cursor (vk_inbox_digest_cursor) makes retries idempotent.
Frequency caps (@venturekit/notify/policy): checkFrequencyCap(query, { userId, channel: 'email', max: 3, windowDays: 7, excludeTemplateKeys: ['password_reset'] })
answers { allowed, remaining, windowResetsAt } against the outbox — apply it to
the categories a user may opt out of; transactional templates are never asked.
Push devices
@venturekit/notify/devices keeps the phones a person can be pushed to
(vk_push_devices) and pushes to people rather than to tokens. The app hands its token
over once signed in and takes it back at sign-out; each row names the provider the token
belongs to, and a push goes through that provider:
import { registerPushDevice, revokePushDevice, pushToUsers, nativePushProvider } from '@venturekit/notify/devices';
// PUT /me/push-device — the app's native token (expo-notifications' getDevicePushTokenAsync)
await registerPushDevice(tx.query, { userId, tenantId, token, platform, provider: nativePushProvider(platform), app: 'driver', locale: 'fr' });
// DELETE /me/push-device — while the session can still say so
await revokePushDevice(tx.query, { userId, tenantId, token });
// every live phone of these people, each through its provider; a token the provider says is gone is revoked
const sent = await pushToUsers(tx.query, { userIds, tenantId, app: 'driver', message: { title, body, data: { tripId } } }, {
fcm: { serviceAccount: firebaseKeyJson, format: 'expo-notifications' },
apns: { key: { teamId, keyId, privateKey: p8 }, environment: 'production', format: 'expo-notifications', topics: { driver: 'com.acme.driver' } },
});| provider | Sent through | Credentials | Answer |
| --- | --- | --- | --- |
| fcm | Firebase Cloud Messaging HTTP v1, one request per token | the Firebase service-account key (an OAuth token is minted from it and cached) | final, per token |
| apns | Apple's push service over HTTP/2, to the app's bundle id (topics[app], else topic) | an APNs auth key (.p8, key id, team id; an ES256 provider token is signed and cached) | final, per token |
| expo | Expo's push service, 100 tokens per request | an Expo access token when the project enforces one | a ticket; the receipt later (reconcilePushTickets) |
format: 'expo-notifications' lays the payload out the way expo-notifications reads a
native push (a data-only FCM message; the payload under body on iOS), so the app finds it in
notification.request.content.data as it did through Expo; 'standard' (the default) is the
platforms' own notification fields. A provider left out of the config is not a failure of the
others: its devices come back not_configured, kept. Credentials that cannot work throw before
anything is sent. sendPushToDevices(targets, message, config) is the same send for a product
that has read the devices itself.
The token is the key, per tenant. An install hands the same token back on every launch (until FCM rotates it — register on every launch), so registering again is a
lastSeenAt; a token registered by someone else is re-pointed to them — the phone was handed over — never kept for both.Revoked, never deleted, with a reason (
signed_out,device_gone,user_removed,stale): "why does my phone not ring" has an answer.revokeUserPushDeviceswhen an account goes,revokePushTokensfor what the provider reports dead,prunePushDevicesin a cron (revoke what is unseen for months, delete what was revoked long ago),forgetPushDevicesfor an erasure request.An accepted push is not a read one. FCM and APNs answer finally at the send (
gonetokens are revoked bypushToUsers,retryablerefusals counted apart). Expo answers with a ticket per device; keep them and ask for their receipts a few minutes later withreconcilePushTickets, which revokes a device the receipt says is gone. None says the person saw it.Through the outbox, with retries and an audit row per phone: give the client the directory, and a push to a person fans out at
enqueue; the dispatcher revokes a token the provider reports gone (PushDeviceGoneError). The outbox takes one push provider (createFcmPushProvider,createApnsPushProvider({ topic })orcreateExpoPushProvider).const notify = createNotifyClient({ store, providers: [createExpoPushProvider()], pushDevices: pushDeviceDirectory(tx.query, { tenantId }) }); await notify.enqueue({ recipientUserId: userId, channel: 'push', templateKey: 'bus_near' }); // a row per phone; none → `no-address`
Multi-tenant. tenant_id is uuid, like vk_tenants; guard the table like your own:
SELECT vk_install_tenant_guards('public', 'vk_push_devices', '<tenant role>'); in your
migration. The table's migration is named 0000_vk_notify_push_devices.sql so it exists
before your files run — your migration can install guards on it, reference your tenants, or
copy the rows of the table it replaces.
Stored templates (the ones your customers write)
@venturekit/notify/templates keeps templates as data (vk_notification_templates): the
product's defaults (tenantId null) and each tenant's copy, per key and locale,
versioned, a draft until approved. Rendering is renderStringTemplate's — an
unresolved placeholder blocks the send.
import { publishDefaultTemplate, saveTemplateDraft, approveTemplateDraft, renderStoredTemplate } from '@venturekit/notify/templates';
// the product ships its defaults (a seed or a deploy step; a new version only when the text changed)
await publishDefaultTemplate(query, { key: 'trip_started', locale: 'fr', title: 'Le bus est parti', body: '{child_name} est en route.', category: 'operational' });
// PUT /templates — the tenant rewords it: a draft, inheriting {child_name}
const draft = await saveTemplateDraft(tx.query, { tenantId, key: 'trip_started', locale: 'fr', title, body, createdBy: userId });
// POST /templates/{id}/approve — from here the version is immutable and may be sent
await approveTemplateDraft(tx.query, { id: draft.id, tenantId, approvedBy: userId });
// the send path: the tenant's approved copy, else the default, in the reader's language
const out = await renderStoredTemplate(tx.query, { key: 'trip_started', tenantId, preferred: [reader.locale, tenant.locale], values: { child_name } });
if (out.ok) await notify.enqueue({ /* out.title, out.body */ });- Only approved versions are sent, and an approved version never changes: rewording it writes the next version as a draft, so what went out under version 2 can be read back. One open draft per tenant, key and locale, edited in place.
- Placeholders are declared. A tenant's copy declares exactly what its default declares;
approval (and saving) refuses one it does not (
StoredTemplateError, codeundeclared_placeholders, withplaceholdersandallowedto show). - The product's own words — a default published with
overridable: false— have no tenant copy: saving one is refused (not_overridable), and one written behind the helpers' back is never read. - Per locale, a tenant's copy wins over the default; a tenant that translated a message into French only still sends the product's English to English readers.
standalone: truesaves a tenant key with no default behind it (a snippet the tenant keeps for itself); its placeholders are whatever its text uses.
Multi-tenant. Defaults must stay readable by every tenant, so the standard tenant guard
(which hides NULL tenants) is not the one: in your migration, read
tenant_id IS NULL OR tenant_id = ANY (vk_tenant_scope()) and write
tenant_id = vk_acting_tenant(), granted to your tenant role. The migration is
0000_vk_notify_templates.sql, so your files can seed defaults into the table and install
that policy.
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 |
| VENTURE_NOTIFY_WHATSAPP_APP_SECRET | Secrets Manager ARN for the Meta App Secret (only when whatsapp.webhooks: true) |
| VENTURE_NOTIFY_WHATSAPP_VERIFY_TOKEN_SECRET | Secrets Manager ARN for the webhook verify token (only when whatsapp.webhooks: true) |
| 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
