import { RetryStrategy as RetryStrategy_dwxcm3 } from "@workkit/errors";
type ChannelName = string;
type DeliveryStatus = "queued" | "sent" | "delivered" | "read" | "failed" | "bounced" | "skipped" | "duplicate";
type DispatchMode = "live" | "test";
interface ChannelTemplate
{
/**
* The template body. Adapters interpret it differently:
* - `email`: `string` HTML, or a React Email element rendered via the
* optional `@react-email/render` peer.
* - `whatsapp`: typically a template id or full template object.
* - others: as the adapter documents.
*
* Typed as `unknown` so adapters can accept their own narrower shape
* without forcing every other adapter to deal with it.
*/
template?: unknown;
variables?: (payload: P) => Record;
props?: (payload: P) => unknown;
attachments?: (payload: P) => Array<{
filename?: string;
r2Key: string;
type?: string;
}>;
title?: (payload: P) => string;
body?: (payload: P) => string;
deepLink?: (payload: P) => string;
}
interface AdapterSendArgs {
userId: string;
notificationId: string;
channel: ChannelName;
address: string;
template: ChannelTemplate
;
payload: P;
deliveryId: string;
mode: DispatchMode;
}
interface AdapterSendResult {
providerId?: string;
status: Exclude;
error?: string;
/**
* Optional. Whether the failure should be retried. Adapters that catch
* a `WorkkitError` populate this from `WorkkitError.retryable`; other
* adapters can leave it undefined. See ADR-002.
*/
retryable?: boolean;
/**
* Optional. Recommended backoff strategy for the failure. Adapters that
* catch a `WorkkitError` populate this from `WorkkitError.retryStrategy`.
* Consumers / queue policy can opt into reading this field; today it is
* not yet acted on by `createNotifyConsumer` (see ADR-002 follow-ups).
*/
retryStrategy?: RetryStrategy_dwxcm3;
}
interface WebhookEvent {
channel: ChannelName;
providerId: string;
status: Extract;
at: number;
raw?: unknown;
}
interface Adapter {
send(args: AdapterSendArgs
): Promise;
parseWebhook?(req: Request): Promise;
verifySignature?(req: Request, secret: string): Promise;
}
/** Minimal D1-shape we depend on. Matches @cloudflare/workers-types' D1Database. */
interface NotifyD1 {
prepare(query: string): NotifyPreparedStatement;
batch(statements: NotifyPreparedStatement[]): Promise;
}
interface NotifyPreparedStatement {
bind(...values: unknown[]): NotifyPreparedStatement;
first>(): Promise;
all>(): Promise<{
results?: T[];
}>;
run(): Promise<{
success?: boolean;
meta?: {
changes?: number;
};
}>;
}
interface StopMatchOptions {
extraKeywords?: ReadonlyArray;
}
declare function isStopKeyword(text: string, options?: StopMatchOptions): boolean;
/** Exposed for tests + consumer introspection. */
declare function defaultStopKeywords(): ReadonlyArray;
/**
* In-memory marketing-pause flag, single-Worker-isolate scope.
*
* Quality-rating webhooks call `pause()` to halt `category: marketing` sends
* pending operator review. Multi-isolate fan-out is a v2 concern (Durable
* Object). For v1, Meta's quality metric is slow-moving — a 30s propagation
* gap across isolates is acceptable.
*
* Audit hook receives every transition so callers can persist + alert.
*/
interface MarketingPauseAuditEvent {
at: number;
state: "paused" | "resumed";
reason: string;
}
type MarketingPauseAuditHook = (event: MarketingPauseAuditEvent) => void | Promise;
declare class MarketingPauseRegistry {
private paused;
private reason;
private auditHook;
constructor(options?: {
auditHook?: MarketingPauseAuditHook;
});
isPaused(): boolean;
pauseReason(): string | undefined;
pause(reason: string): Promise;
resume(reason: string): Promise;
}
declare function assertE164(value: string): string;
declare function isE164(value: string): boolean;
/**
* Optional cipher hook for storing phone numbers at rest. Caller supplies
* AES-GCM (or any symmetric cipher) `encrypt`/`decrypt` callbacks. The
* default is identity — phones stored as plain E.164. Switching to a real
* cipher later does not require a schema migration since the column is
* declared TEXT (Base64-friendly).
*/
interface PhoneCipher {
encrypt(plain: string): Promise;
decrypt(cipher: string): Promise;
}
declare const identityCipher: PhoneCipher;
type WhatsAppCategory = "marketing" | "transactional" | "authentication";
interface WaTemplateRef {
name: string;
language: string;
variables?: ReadonlyArray;
category?: WhatsAppCategory;
}
interface WaMediaRef {
mediaId: string;
mimeType?: string;
}
interface WaSendArgs {
toE164: string;
template?: WaTemplateRef;
sessionText?: string;
media?: WaMediaRef;
}
interface WaSendResult {
providerId: string;
}
interface WaUploadArgs {
bytes: Uint8Array;
mimeType: string;
filename?: string;
}
/**
* Inbound message events parsed from a webhook. The adapter inspects the
* text body for STOP keywords and the type for quality-rating updates.
*/
interface WaInboundMessage {
from: string;
text?: string;
at: number;
raw?: unknown;
}
interface WaQualityAlert {
level: "low" | "medium" | "high" | "flagged";
at: number;
raw?: unknown;
}
/**
* Provider events: a union of delivery `WebhookEvent`s (mapped to the
* notify-core webhook shape), inbound messages, and account-quality
* alerts. The adapter routes each variant.
*/
type WaProviderEvent = {
kind: "delivery";
event: WebhookEvent;
} | {
kind: "inbound";
message: WaInboundMessage;
} | {
kind: "quality";
alert: WaQualityAlert;
};
/**
* Pluggable provider interface. `metaWaProvider` is the reference impl;
* `twilioWaProvider` and `gupshupWaProvider` are stubs.
*/
interface WaProvider {
readonly name: "meta" | "twilio" | "gupshup";
send(args: WaSendArgs): Promise;
uploadMedia(args: WaUploadArgs): Promise;
parseWebhook(req: Request): Promise;
verifySignature(req: Request, secret: string): Promise;
/**
* Meta requires a one-shot `GET ?hub.mode=subscribe&hub.challenge=…&hub.verify_token=…`
* handshake on webhook setup. Providers that don't need this can return null.
*/
handleVerificationChallenge(req: Request, verifyToken: string): Response | null;
}
interface WhatsAppPayload {
[key: string]: unknown;
}
interface R2BucketLike {
get(key: string): Promise<{
body: ReadableStream | null;
arrayBuffer(): Promise;
etag?: string;
httpMetadata?: {
contentType?: string;
};
} | null>;
}
/**
* Pluggable check for "is the recipient on the DND registry?". Adapter
* only invokes this for `category: "marketing"` templates. Returning true
* suppresses the send and is reported through the adapter's existing
* failure result path (`AdapterSendResult.status` does not include
* `skipped`).
*/
type DndChecker = (phoneE164: string) => Promise;
/**
* Caller-provided opt-out hook. Invoked when an inbound message matches a
* STOP/UNSUBSCRIBE keyword (after webhook signature verification).
* `notificationId: null` because STOPs are global by intent.
*/
type WaOptOutHook = (userId: string, channel: "whatsapp", notificationId: null, reason: "inbound-stop") => Promise;
interface WhatsAppTemplateRef {
name: string;
language: string;
variables?: (payload: WhatsAppPayload) => ReadonlyArray;
category: WhatsAppCategory;
media?: (payload: WhatsAppPayload) => {
r2Key: string;
mimeType?: string;
} | undefined;
}
interface WhatsAppAdapterOptions {
provider: WaProvider;
db: NotifyD1;
bucket?: R2BucketLike;
cipher?: PhoneCipher;
pauseRegistry?: MarketingPauseRegistry;
dndCheck?: DndChecker;
optOutHook?: WaOptOutHook;
stopKeywords?: StopMatchOptions;
/** Optional resolver: webhook payload `from` (E.164) → your internal userId. */
userIdFromPhone?: (phoneE164: string) => Promise;
/** Force template send even when inside the 24h session window (rare; defaults false). */
forceTemplate?: boolean;
}
declare function whatsappAdapter(options: WhatsAppAdapterOptions): Adapter;
interface MetaWaProviderOptions {
accessToken: string;
phoneNumberId: string;
apiUrl?: string;
graphVersion?: string;
}
declare function metaWaProvider(options: MetaWaProviderOptions): WaProvider;
interface TwilioWaProviderOptions {
accountSid: string;
authToken: string;
fromNumber: string;
apiUrl?: string;
}
/**
* Stub. The provider interface is fixed so a real implementation can drop
* in without touching the adapter or any caller code.
*/
declare function twilioWaProvider(_options: TwilioWaProviderOptions): WaProvider;
interface GupshupWaProviderOptions {
apiKey: string;
appName: string;
apiUrl?: string;
}
/** Stub. Provider interface is stable. */
declare function gupshupWaProvider(_options: GupshupWaProviderOptions): WaProvider;
interface OptInProof {
userId: string;
phoneE164: string;
optedInAt: number;
method: string;
sourceUrl?: string;
ipHash?: string;
userAgent?: string;
revokedAt?: number;
revokeReason?: string;
}
interface RecordOptInArgs {
userId: string;
phoneE164: string;
method: string;
sourceUrl?: string;
ipHash?: string;
userAgent?: string;
}
interface OptInDeps {
db: NotifyD1;
cipher?: PhoneCipher;
now?: () => number;
}
declare function recordOptIn(deps: OptInDeps, args: RecordOptInArgs): Promise;
declare function revokeOptIn(deps: OptInDeps, userId: string, reason: string): Promise;
declare function isOptedIn(deps: OptInDeps, userId: string): Promise;
declare function getOptInProof(deps: OptInDeps, userId: string): Promise;
interface SessionWindowDeps {
db: NotifyD1;
now?: () => number;
}
/**
* Record an inbound message — used to determine whether the recipient is
* inside the WhatsApp 24h customer-service window.
*/
declare function recordInbound(deps: SessionWindowDeps, args: {
userId: string;
at?: number;
text?: string;
}): Promise;
/**
* True iff the most recent inbound message from `userId` is within the WA
* 24h window. Returns false when no inbound has been recorded.
*/
declare function withinSessionWindow(deps: SessionWindowDeps, userId: string): Promise;
declare const SESSION_WINDOW_MS: number;
interface MediaCacheDeps {
db: NotifyD1;
now?: () => number;
}
interface CachedMedia {
mediaId: string;
mimeType?: string;
bytes?: number;
uploadedAt: number;
expiresAt?: number;
}
declare function cacheKey(provider: string, r2Key: string, etag: string): string;
declare function getCached(deps: MediaCacheDeps, key: string): Promise;
declare function putCached(deps: MediaCacheDeps, key: string, args: {
provider: string;
mediaId: string;
mimeType?: string;
bytes?: number;
ttlMs?: number;
}): Promise;
declare function purgeExpiredMedia(deps: MediaCacheDeps): Promise<{
deleted: number;
}>;
declare const DEFAULT_MEDIA_TTL_MS: number;
interface ForgetWhatsAppResult {
optInRowsDeleted: number;
mediaCacheRowsDeleted: number;
inboundLogRowsDeleted: number;
}
/**
* Cascade-delete a user's WhatsApp footprint from D1: opt-in proof,
* inbound message log. Media-cache rows are NOT user-keyed (they're keyed
* by R2 etag) so we leave them; the global TTL purge handles eviction.
*
* Caller should also invoke `@workkit/notify`'s `forgetUser` to drop the
* preferences/opt-out/delivery-record rows in the same transaction.
*/
declare function forgetWhatsAppUser(db: NotifyD1, userId: string): Promise;
/**
* D1 schema for `@workkit/notify/whatsapp`. Run alongside `ALL_MIGRATIONS`
* from `@workkit/notify` and `INAPP_MIGRATION_SQL` from
* `@workkit/notify/inapp`.
*/
declare const WA_OPTIN_MIGRATION_SQL: string;
declare const WA_MEDIA_CACHE_MIGRATION_SQL: string;
declare const WA_INBOUND_LOG_MIGRATION_SQL: string;
declare const WA_ALL_MIGRATIONS: ReadonlyArray;
import { ConfigError, ValidationError } from "@workkit/errors";
declare class OptInRequiredError extends ValidationError {
constructor(userId: string, channel?: string);
}
declare class TemplateNotApprovedError extends ConfigError {
constructor(reason: string);
}
declare class WhatsAppPhoneFormatError extends ValidationError {
constructor(value: string);
}
declare class WhatsAppWebhookSignatureError extends ValidationError {
constructor(reason: string);
}
declare class MarketingPausedError extends ValidationError {
constructor(notificationId: string);
}
export { withinSessionWindow, whatsappAdapter, twilioWaProvider, revokeOptIn, recordOptIn, recordInbound, putCached, purgeExpiredMedia, metaWaProvider, isStopKeyword, isOptedIn, isE164, identityCipher, gupshupWaProvider, getOptInProof, getCached, forgetWhatsAppUser, defaultStopKeywords, cacheKey, assertE164, WhatsAppWebhookSignatureError, WhatsAppTemplateRef, WhatsAppPhoneFormatError, WhatsAppPayload, WhatsAppCategory, WhatsAppAdapterOptions, WaUploadArgs, WaTemplateRef, WaSendResult, WaSendArgs, WaQualityAlert, WaProviderEvent, WaProvider, WaOptOutHook, WaMediaRef, WaInboundMessage, WA_OPTIN_MIGRATION_SQL, WA_MEDIA_CACHE_MIGRATION_SQL, WA_INBOUND_LOG_MIGRATION_SQL, WA_ALL_MIGRATIONS, TwilioWaProviderOptions, TemplateNotApprovedError, StopMatchOptions, SESSION_WINDOW_MS, RecordOptInArgs, PhoneCipher, OptInRequiredError, OptInProof, OptInDeps, MetaWaProviderOptions, MarketingPausedError, MarketingPauseRegistry, MarketingPauseAuditHook, MarketingPauseAuditEvent, GupshupWaProviderOptions, ForgetWhatsAppResult, DndChecker, DEFAULT_MEDIA_TTL_MS, CachedMedia };