@venturekit/notify API
Client
Section titled “Client”| Function | Description |
|---|---|
createNotifyClient(options) | Build the notify client (store, defaultFrom, providers, addressResolver) |
NotifyClient interface (selected methods):
| Method | Description |
|---|---|
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.
| Function | Description |
|---|---|
createPostgresNotifyStore(options?) | Postgres-backed outbox store implementing NotifyStore |
Providers
Section titled “Providers”| Function | Description |
|---|---|
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.
Dispatcher & Bounce Handler
Section titled “Dispatcher & Bounce Handler”| Function | Description |
|---|---|
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.
Admin Helpers
Section titled “Admin Helpers”Exported from @venturekit/notify/admin — wire into your own handlers:
| Function | Description |
|---|---|
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 |
Templates
Section titled “Templates”| Function | Description |
|---|---|
createTemplateRegistry() | In-memory template registry (TemplateRegistry) |
Errors: TemplateNotFoundError.
Meta WhatsApp webhooks
Section titled “Meta WhatsApp webhooks”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: '' });});| Export | Description |
|---|---|
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_ENV | The two env-var names above |
Meta language codes
Section titled “Meta language codes”Exported from the root and from @venturekit/notify/providers/whatsapp-meta-language — bridge app locales and the codes Meta’s template APIs expect:
| Export | Description |
|---|---|
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' } |
Expo push provider (/providers/push-expo)
Section titled “Expo push provider (/providers/push-expo)”A real push provider for React Native apps — Expo fronts APNs and FCM behind one HTTP call, no platform ARNs or certificates.
| Export | Description |
|---|---|
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 |
Policy helpers (/policy)
Section titled “Policy helpers (/policy)”Pure functions for the decisions that come before a send. The mechanism lives here; the policy (which channel first, whose quiet hours) stays yours.
| Export | Description |
|---|---|
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 |
Inbox (/inbox)
Section titled “Inbox (/inbox)”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.
| Export | Description |
|---|---|
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.