Skip to content

@venturekit/notify API

FunctionDescription
createNotifyClient(options)Build the notify client (store, defaultFrom, providers, addressResolver)

NotifyClient interface (selected methods):

MethodDescription
enqueue(input: EnqueueInput)Write a notification to the outbox (the 99% case); returns EnqueueResult
sendNow(input: SendNowInput)Render + send immediately, bypassing the outbox/cron; returns SendNowResult
registerTemplate(template: NotifyTemplate)Register an in-code template (generic over the payload type)
registerTemplates(templates: NotifyTemplate[])Register many templates at once
registerProvider(provider: NotifyProvider)Register / override a channel provider

The client also exposes the configured store, templates, providers, defaultFrom, and addressResolver for use in admin handlers.

FunctionDescription
createPostgresNotifyStore(options?)Postgres-backed outbox store implementing NotifyStore
FunctionDescription
createSesEmailProvider(config)AWS SESv2 email provider
createMetaWhatsAppProvider(config)Meta Cloud API WhatsApp provider
createSecretsTokenResolver(options)Resolve a provider token from Secrets Manager
createEmailNoopProvider(options?)Log-only email provider (dev)
createMailHogProvider(config)SMTP provider targeting MailHog (local dev)
createSmsNoopProvider() / createPushNoopProvider()Placeholder providers

Errors: ProviderHardError, ProviderSendError.

FunctionDescription
dispatchOnce(options)Drain pending outbox rows once (used by the cron Lambda)
makeDispatchHandler(options?)Build the EventBridge cron dispatcher handler
makeBounceHandler(options?)Build the SNS bounce/complaint handler

Imported from @venturekit/notify/dispatcher and @venturekit/notify/bounce-handler.

Exported from @venturekit/notify/admin — wire into your own handlers:

FunctionDescription
adminListNotifications(client, options)Paginated list with status/channel/template/search filters
adminGetNotification(client, id)Fetch one notification
adminSendNotification(client, input)Enqueue (separate name for audit logging)
adminRetryNotification(client, id)Flip failed/bounced back to pending
adminCancelNotification(client, id)Cancel a pending row before dispatch
adminClearSuppression(client, channel, address)Lift a bounce/complaint suppression
listUserPreferences(client, userId) / upsertUserPreference(client, ...)Per-user opt-in management
listNotificationsForUser(client, userId, options)Buyer-portal inbox view
FunctionDescription
createTemplateRegistry()In-memory template registry (TemplateRegistry)

Errors: TemplateNotFoundError.

Exported from the package root and from @venturekit/notify/providers/whatsapp-meta-webhook. Meta pushes a webhook when a template’s approval status changes (message_template_status_update) or when it re-categorises a template after approval (template_category_update, e.g. UTILITY → MARKETING, which changes what each message is billed as). These helpers are the wire-format half of an endpoint — HTTP-framework-agnostic and DB-free.

import { handler, rawResult } from '@venturekit/runtime';
import { createMetaWhatsAppWebhookHandler, MetaWebhookSignatureError, MetaWebhookVerificationError } from '@venturekit/notify';
const wa = createMetaWhatsAppWebhookHandler(); // zero-arg in prod: secrets come from the intent's env vars
export const main = handler(async (_body, ctx) => {
const { method } = ctx.rawEvent.requestContext.http;
if (method === 'GET') { // one-time subscription handshake
const challenge = await wa.handleVerification(ctx.rawEvent.queryStringParameters ?? {});
return rawResult({ statusCode: 200, body: challenge });
}
await wa.verifySignature(ctx.rawEvent.body ?? '', ctx.rawEvent.headers['x-hub-signature-256']);
for (const event of wa.parseEvents(ctx.rawEvent.body)) {
if (event.type === 'template_category_changed') { /* re-price the template */ }
}
return rawResult({ statusCode: 200, body: '' });
});
ExportDescription
createMetaWhatsAppWebhookHandler(config?)Binds the App Secret and verify token — resolved lazily (once per cold start) from the secrets whose ARNs are in VENTURE_NOTIFY_WHATSAPP_APP_SECRET / VENTURE_NOTIFY_WHATSAPP_VERIFY_TOKEN_SECRET (provisioned by whatsapp.webhooks: true); appSecretResolver / verifyTokenResolver override them. Returns { verifySignature(rawBody, signature), handleVerification(query), parseEvents(body) }
verifyMetaWebhookSignature({ rawBody, signature, appSecret })Constant-time HMAC-SHA256 check of x-hub-signature-256 (the sha256= prefix is required); throws MetaWebhookSignatureError
handleMetaWebhookVerification({ query, verifyToken })The hub.mode=subscribe / hub.verify_token / hub.challenge GET handshake; returns the challenge string or throws MetaWebhookVerificationError (respond 403)
parseMetaWebhookEvents(body)Flattens entry[].changes[] into a discriminated MetaWebhookEvent[] — template_category_changed, template_category_change_scheduled (normalised: Meta reuses new_category there to mean the current one), template_status_updated, plus unknown for unmodelled fields so new Meta fields never break the endpoint. Malformed envelopes throw MetaWebhookPayloadError
META_SIGNATURE_HEADER'x-hub-signature-256'
WHATSAPP_APP_SECRET_ENV / WHATSAPP_VERIFY_TOKEN_SECRET_ENVThe two env-var names above

Exported from the root and from @venturekit/notify/providers/whatsapp-meta-language — bridge app locales and the codes Meta’s template APIs expect:

ExportDescription
toMetaLanguage(locale, map?)'en' → 'en_US', 'pt' → 'pt_BR', 'fr' → 'fr', 'en-GB' → 'en_GB', 'zh-Hant-TW' → 'zh_TW'; map replaces the regional-default table
fromMetaLanguage(code)'en_US' / 'en-US' → 'en' — accepts both the API’s and the webhooks’ spelling
DEFAULT_META_LANGUAGE_MAP{ en: 'en_US', pt: 'pt_BR', zh: 'zh_CN' }

A real push provider for React Native apps — Expo fronts APNs and FCM behind one HTTP call, no platform ARNs or certificates.

ExportDescription
createExpoPushProvider(config?)A NotifyProvider (name: 'expo-push') where to is one Expo push token — use it with the dispatcher and enqueue a row per device. defaultChannelId, defaultSound
sendExpoPush(messages, config?)The multi-token client underneath — one ticket per message/token (ExpoPushTicketFor[]), for apps that own their outbox and mean “reach this person on whatever they have”
fetchExpoPushReceipts(ticketIds, config?)Record<ticketId, ExpoPushReceipt> — receipts arrive minutes later and live about a day; a ticket only means Expo accepted the message
isDeviceGone(ticketOrReceipt)true on DeviceNotRegistered — stop sending to that token

Pure functions for the decisions that come before a send. The mechanism lives here; the policy (which channel first, whose quiet hours) stays yours.

ExportDescription
buildChannelChain({ order, reachable })An ordered, reachability-filtered fallback chain out of the channel order you declare
nextChannel({ chain, attempted?, deferUntil? })ChainStep — { action: 'attempt', channel, remaining }, { action: 'hold', channel, until, remaining } (a channel deferred by quiet hours) or { action: 'exhausted' }
inQuietHours(at, { start, end, timezone }) / quietHoursEnd(at, quiet)Recipient-timezone quiet-hours arithmetic that survives windows crossing midnight and DST changes
renderStringTemplate(template, values, { escape? })Operator-owned {placeholder} templates stored as data (StringTemplate: key, locale, title, body) → RenderOutcome. Values are spliced in verbatim unless escape is given — pass escapeHtml whenever the result lands in an HTML e-mail or anything else that interprets tags, since the values are typically user-supplied; leave it unset for plain-text channels (SMS, WhatsApp)
escapeHtml(value)Escapes & < > " ' — enough for element content and quoted attributes
placeholdersOf(text) / undeclaredPlaceholders(template)Introspect placeholders for validation forms
resolveLocale({ preferred, available })First preferred locale (with language-only fallback) that a template set actually has, or null
decideFrequencyCap({ sent, max, oldestSentAt?, windowDays?, now? })The pure rule → FrequencyCapDecision { allowed, sent, max, remaining, windowResetsAt? } — windowResetsAt is when the oldest counted message leaves the window, i.e. the earliest instant another send is allowed
countSentInWindow(querier, { userId, channel?, windowDays?, excludeTemplateKeys?, includeTemplateKeys? })Messages sent/delivered to a user in vk_notifications over the trailing window (default 7 days) plus the oldest sent_at; exclude transactional / security templates so they never count
checkFrequencyCap(querier, { …countOptions, max, now? })Count then decide, in one call — the “at most 3 marketing e-mails per member per week” rule

The in-app notification centre — the bell — stored in its own table (vk_inbox_notifications, migration vk_notify_003) so the delivery outbox, dispatcher and channel enum are untouched. Every function takes a Querier ((sql, params?) => Promise<rows>) — pass @venturekit/data’s querier or a transaction’s — and the read/write helpers are scoped to userId, so a client can never flip another user’s rows by guessing ids.

ExportDescription
createInboxNotification(q, { userId, type, title, body?, tenantId?, messageKey?, messageParams?, referenceKind?, referenceId?, data?, groupKey? })Write one row → InboxNotification. title is the complete sentence in the sender’s locale (the fallback); messageKey + messageParams let the client render in the reader’s locale. referenceKind / referenceId are the click target. With groupKey, an existing row for the same user and key is replaced (content refreshed, readAt reset, createdAt moved to now) so repeated events collapse into one entry
listInbox(q, { userId, tenantId?, unreadOnly?, types?, before?, limit? })Newest first, keyset-paginated on createdAt (before = the last row’s createdAt); limit 1–100, default 20; types lets the app drop muted types
unreadInboxCount(q, { userId, tenantId?, types? })Badge count
markInboxRead(q, { userId, ids }) / markAllInboxRead(q, { userId, tenantId?, before? })Returns rows changed; before limits “mark all” to what the user has actually seen
deleteInboxNotification(q, { userId, id })true when a row was removed
pruneInbox(q, { olderThanDays?, unreadOlderThanDays? })Retention sweep for a cron: read rows older than 90 days by default, unread ones only when asked
generateInboxId()notif_ + UUID without dashes
runInboxDigest(q, { enqueue, templateKey, tenantId?, minAgeMinutes?, maxItems?, maxPerUserPerDay?, maxUsers?, types?, shouldSend?, now? })One cron tick of the e-mail digest: for each user with unread rows older than minAgeMinutes (default 30) since their last digest, enqueue one email notification through your notify client (templateKey, payload { items, unreadCount }, relatedKind: 'inbox_digest') so templates, preferences and suppression still apply. A per-user cursor (vk_inbox_digest_cursor, migration vk_notify_004) makes re-runs idempotent; maxPerUserPerDay (default 1) caps digests per rolling day; shouldSend(userId) is your opt-out hook. Returns { usersConsidered, digestsSent, skippedByCap, skippedByCallback }

NotifyChannel, NotificationStatus, NotifyAddress, NotificationRow, NotificationPreferenceRow, NotificationEventLogRow, EnqueueInput, EnqueueResult, SendNowInput, SendNowResult, RenderedMessage, NotifyTemplate, NotifyClient, CreateNotifyClientOptions, AddressResolver, NotifyStore, NotifyProvider, MetaWebhookEvent, MetaWhatsAppWebhookHandler, ExpoPushProviderConfig, ChannelChainOptions, ChainStep, QuietHours, StringTemplate, RenderStringTemplateOptions, ResolveLocaleOptions, FrequencyCapDecision, CountSentInWindowOptions; from /inbox: InboxNotification, CreateInboxNotificationInput, ListInboxOptions, RunInboxDigestOptions, DigestEnqueueInput, InboxDigestResult.