Overview
Provider-agnostic outbound message dispatcher for the AbsoluteJS ecosystem.
Provider-agnostic outbound dispatcher for Bun + Elysia — send email, carrier/rich messaging, and push through one typed interface. Swap Resend, Postmark, AWS, Infobip, Sinch, Telnyx, Twilio, or Vonage without touching call sites, test with the bundled in-memory adapters, and get OpenTelemetry spans and audit events on every send.
Choose the application-level channel first; provider adapters remain replaceable beneath the same policy and evidence boundary.
| Option | Use when | Portable content | Typical adapters |
|---|---|---|---|
| Rich transactional or lifecycle communication | Subject, text/HTML, sender, recipients, headers | Postmark, AWS SES, Resend | |
| Messaging | SMS, MMS, RCS, WhatsApp, and social conversations | Content, fallbacks, consent, privacy, schedule | Twilio, Telnyx, Vonage, Sinch, Infobip |
| Push | Device notifications and deep-link re-engagement | Title, body, data, actions, badge, sound | APNs, FCM |
One send produces a consistent policy, provider, observability, and callback trail across every adapter.
Install the core and only the provider, persistence, and policy packages your application uses. Every package is a real npm release; the ecosystem does not rely on local file dependencies, overrides, or publish-time workspace tricks.
bun add @absolutejs/dispatch@0.7.1
# Install only the capabilities you use:
bun add @absolutejs/dispatch-resend resend
bun add @absolutejs/dispatch-apns @absolutejs/dispatch-fcm
bun add @absolutejs/dispatch-push-postgres @absolutejs/reliability
bun add @absolutejs/dispatch-aws-end-user-messaging
bun add @absolutejs/dispatch-infobip
bun add @absolutejs/dispatch-twilio @absolutejs/compliance twilio
# OTP/MFA is a separate Auth concern:
bun add @absolutejs/auth @absolutejs/auth-twilio twiliocreateDispatcher() takes one optional adapter per channel. Each channel becomes a top-level callable — dispatch.email(message), dispatch.messaging(message), dispatch.push(message). Calling a channel you didn't configure throws DispatchUnsupportedError, so the omission is loud, not silent.
import {
createDispatcher,
memoryEmailAdapter
} from '@absolutejs/dispatch';
const email = memoryEmailAdapter({
idGenerator: () => 'local-message-1'
});
const dispatch = createDispatcher({
email,
defaultFrom: { email: 'Example <noreply@example.com>' },
});
// Each channel is called directly — dispatch.email(...), dispatch.messaging(...).
const result = await dispatch.email({
to: 'user@example.com',
subject: 'Welcome',
text: 'Hi there!',
});
console.log(result.provider); // memory
console.log(email.inspect()); // exactly one normalized messageThe ecosystem separates authored messages, consent, durability, provider-managed verification, and voice into explicit contracts. This keeps OTP out of the alerting layer and carrier compliance out of individual call sites.
@absolutejs/dispatch-twilio for copy your application authors. Use @absolutejs/auth-twilio when Twilio Verify must generate and validate the secret. The Auth MFA guide contains the complete Verify setup.The compliance ledger keys evidence by tenant, recipient, program, purpose, and every transport the provider may use. The Dispatch policy performs the durable lookup before the adapter runs. Signed STOP/START callbacks from Twilio, Telnyx, Vonage, and Sinch can update the same ledger.
import { createDispatcher } from '@absolutejs/dispatch';
import {
createMessagingConsentDispatchPolicy,
createMessagingConsentLedger,
createPostgresMessagingConsentStore,
} from '@absolutejs/compliance';
const consent = createMessagingConsentLedger({
audit,
store: createPostgresMessagingConsentStore(postgres),
});
await consent.grant({
programId: 'acme-incident-alerts',
purpose: 'incident-alerts',
recipient: '+12025550100',
tenant: 'tenant-a',
transport: 'sms',
}, {
at: Date.now(),
reference: 'signup-42',
source: 'signup-form',
});
const dispatch = createDispatcher({
messaging,
policies: [createMessagingConsentDispatchPolicy({ ledger: consent })],
});
// Missing or revoked evidence is denied before a provider request is made.
await dispatch.messaging({
consent: { programId: 'acme-incident-alerts', purpose: 'incident-alerts' },
content: { kind: 'text', text: 'Database latency is elevated.' },
tenant: 'tenant-a',
to: { address: '+12025550100', transport: 'sms' },
});Messaging adapters normalize provider callbacks into one event model, but durability is deliberately split from HTTP intake. Use the PostgreSQL inbox and transaction runner from @absolutejs/reliability in production; the memory store is for tests and local development.
createPushLifecycle() adds the shared layer above APNs and FCM: tenant-safe device registration, user/device/topic targeting, multi-tenant adapter resolution, bounded retries and concurrency, invalid-token retirement, and portable badges, sounds, actions, and deep links. PostgreSQL persistence keeps registrations durable and uses fenced claims so ambiguous sends become indeterminate instead of double-delivering.
import { createPushLifecycle } from '@absolutejs/dispatch';
import {
createPostgresPushFanoutClaimStore,
createPostgresPushSubscriptionStore,
} from '@absolutejs/dispatch-push-postgres';
import {
createPostgresIdempotentOperationStore,
createPostgresTransactionRunner,
} from '@absolutejs/reliability';
const runner = createPostgresTransactionRunner(postgresPool);
const push = createPushLifecycle({
adapterFor: ({ platform, tenant }) => resolveTenantPushAdapter(tenant, platform),
claimStore: createPostgresPushFanoutClaimStore(
createPostgresIdempotentOperationStore(runner),
),
store: createPostgresPushSubscriptionStore(runner),
});
await push.register({
deviceId: 'iphone-15',
platform: 'apns',
tenant: 'acme',
token,
topics: ['incidents'],
userId: 'user-42',
});
const result = await push.send(
{ tenant: 'acme', topic: 'incidents' },
{
body: 'Database latency is elevated.',
deepLink: 'absolute://incidents/42',
idempotencyKey: 'incident-42:opened',
sound: 'default',
title: 'Production alert',
},
);
if (result.indeterminate > 0) {
// Delivery may have succeeded before the provider acknowledgement was lost.
// Reconcile this state; do not blindly retry it as a new notification.
await operations.recordIndeterminatePush(result);
}Apply PUSH_SUBSCRIPTION_POSTGRES_SCHEMA and IDEMPOTENT_OPERATION_POSTGRES_SCHEMA before starting workers. Registration atomically reconciles the stable tenant/platform/device identity with provider token rotation, retaining the subscription identity and removing superseded token records.
APNs uses short-lived ES256 provider tokens and pooled HTTP/2 sessions. FCM uses HTTP v1 with Application Default Credentials. Portable actions, badges, sounds, and deep links are translated for both providers; advanced provider objects remain available through message metadata.
import { createApnsAdapter } from '@absolutejs/dispatch-apns';
import { createFcmAdapter } from '@absolutejs/dispatch-fcm';
const adapters = {
apns: createApnsAdapter({
bundleId: process.env.APNS_BUNDLE_ID!,
keyId: process.env.APNS_KEY_ID!,
privateKey: process.env.APNS_PRIVATE_KEY!,
teamId: process.env.APNS_TEAM_ID!,
}),
fcm: createFcmAdapter({ projectId: process.env.FCM_PROJECT_ID! }),
};
// The lifecycle passes the same portable fields to either provider.
// APNs maps them into aps; FCM maps Android, APNs, webpush, and data payloads.
const adapterFor = ({ platform }: { platform: 'apns' | 'fcm' }) =>
adapters[platform];
// Drain pooled APNs HTTP/2 sessions during graceful shutdown.
await adapters.apns.dispose();The AWS adapter uses SDK v3 and workload IAM for SMS, MMS, plain or rich RCS, managed Notify templates, and WhatsApp. Use one phone pool per consented use case: a pool containing an approved RCS agent and SMS identity gives AWS-managed RCS fallback without application-side duplicate routing. Readiness checks cover the pool, event configuration set, and Protect fraud controls; registration helpers drive AWS's dynamic regulatory forms.
import { PinpointSMSVoiceV2Client } from '@aws-sdk/client-pinpoint-sms-voice-v2';
import { SocialMessagingClient } from '@aws-sdk/client-socialmessaging';
import { createDispatcher } from '@absolutejs/dispatch';
import {
createAwsEndUserMessagingAdapter,
inspectAwsEndUserMessagingReadiness,
} from '@absolutejs/dispatch-aws-end-user-messaging';
const client = new PinpointSMSVoiceV2Client({ region: process.env.AWS_REGION });
const messaging = createAwsEndUserMessagingAdapter({
client,
configurationSetName: 'pro-alert-events',
messageType: 'TRANSACTIONAL',
originationIdentity: process.env.AWS_EUM_ALERT_POOL_ARN,
protectConfigurationId: process.env.AWS_EUM_PROTECT_ID,
socialClient: new SocialMessagingClient({ region: process.env.AWS_REGION }),
whatsappPhoneNumberId: process.env.AWS_WHATSAPP_PHONE_NUMBER_ID,
});
const dispatch = createDispatcher({ messaging });
await dispatch.messaging({
content: { kind: 'text', text: 'Database latency is elevated.' },
consent: { programId: 'pro-alerts', purpose: 'incident-alerts' },
idempotencyKey: 'incident-42:recipient-7',
to: { address: '+12025550100', transport: 'rcs' },
});
await inspectAwsEndUserMessagingReadiness({
client,
configurationSetName: 'pro-alert-events',
originationIdentity: process.env.AWS_EUM_ALERT_POOL_ARN,
protectConfigurationId: process.env.AWS_EUM_PROTECT_ID,
});Delivery events use the shared durable inbox pattern: verify the deployment's authenticated AWS ingress, persist the raw event before returning 202, then normalize and apply effects from a retryable worker. The registration manager exposes AWS's dynamic field workflow without hiding provider-specific requirements.
import {
createAwsEndUserMessagingEventHandler,
createAwsEndUserMessagingRegistrationManager,
createPostgresTransactionRunner,
createPostgresWebhookInboxStore,
drainAwsEndUserMessagingEventInbox,
} from '@absolutejs/dispatch-aws-end-user-messaging';
const runner = createPostgresTransactionRunner(postgresPool);
const inbox = createPostgresWebhookInboxStore<string>(runner);
const events = createAwsEndUserMessagingEventHandler({
inbox,
// Verify the authenticated EventBridge/SNS ingress your deployment exposes.
verify: (headers, body) => verifyAwsEventIngress(headers, body),
});
app.post('/webhooks/aws-messaging', ({ request }) => events(request));
// Run in a worker. HTTP intake returns 202 after durable storage.
await drainAwsEndUserMessagingEventInbox({
inbox,
onEvent: event => lifecycle.record(event),
});
const registrations = createAwsEndUserMessagingRegistrationManager(client);
// Select the exact current type returned by AWS registration definitions.
const created = await registrations.create({ RegistrationType: selectedRegistrationType });
await registrations.putFields(created.RegistrationId!, registrationFields);
await registrations.submit(created.RegistrationId!);
const status = await registrations.inspect(created.RegistrationId!);Infobip's Messages API provides one global surface for SMS, MMS, RCS, WhatsApp, Viber, Apple Messages for Business, Instagram, LINE, and Messenger. The adapter can validate the exact request before sending, maps portable rich content, and stores authenticated delivery callbacks before effects run. Operations cover 10DLC brands and campaigns plus number resource requests. Provider failover details must be supplied and validated for the channels enabled on the account.
import { createDispatcher } from '@absolutejs/dispatch';
import {
createInfobipAdapter,
createInfobipWebhookHandler,
createPostgresTransactionRunner,
createPostgresWebhookInboxStore,
drainInfobipWebhookInbox,
} from '@absolutejs/dispatch-infobip';
const inbox = createPostgresWebhookInboxStore(
createPostgresTransactionRunner(postgresPool),
);
const messaging = createInfobipAdapter({
apiKey: process.env.INFOBIP_API_KEY,
baseUrl: process.env.INFOBIP_BASE_URL,
defaultSenders: { sms: process.env.INFOBIP_SMS_SENDER },
deliveryWebhookUrl: 'https://example.com/webhooks/infobip',
validateBeforeSend: true,
});
const webhook = createInfobipWebhookHandler({
inbox,
verify: headers => verifyInfobipGatewayAuthorization(headers),
});
app.post('/webhooks/infobip', ({ request }) => webhook(request));
await drainInfobipWebhookInbox({
inbox,
onEvent: event => lifecycle.record(event),
});
const dispatch = createDispatcher({ messaging });
await dispatch.messaging({
content: { kind: 'text', text: 'Database latency is elevated.' },
consent: { programId: 'pro-alerts', purpose: 'incident-alerts' },
to: { address: '+12025550100', transport: 'sms' },
});Inbound messages and delivery/seen receipts normalize into different event kinds. Portable media accepts exactly one URL so additional parts are never silently discarded; validated provider-specific payloads belong under extensions.infobip. Use the operations client for US brand, campaign, registration, and number workflows.
import {
createInfobipOperationsClient,
} from '@absolutejs/dispatch-infobip';
const operations = createInfobipOperationsClient({
apiKey: process.env.INFOBIP_API_KEY!,
baseUrl: process.env.INFOBIP_BASE_URL!,
});
const brand = await operations.createBrand(brandApplication);
const campaign = await operations.createCampaign({
...campaignApplication,
brandId: brand.id,
});
await operations.registerCampaign(String(campaign.id));
await operations.requestNumber(numberRequest);
// Read these from a worker or admin surface until every registration is ready.
await operations.inspectBrand(String(brand.id));
await operations.inspectCampaign(String(campaign.id));Three channels, all optional — configure only the ones you use. Every message shape carries an optional tenant field that propagates to spans and audit events, plus an open metadata record adapters can interpret.
dispatch.email(message)
to, subject, text?, html?, from?, replyTo?, cc?, bcc?, headers?, tenant?, metadata?
Messaging
dispatch.messaging(message)
to, content, fallbacks?, sendAt?, idempotencyKey?, consent?, privacy?, from?, tenant?, extensions?, metadata?
Push
dispatch.push(message)
to, title?, body, data?, actions?, badge?, deepLink?, sound?, idempotencyKey?, tenant?, metadata?
Besides the per-channel adapters, createDispatcher() accepts:
Every channel call resolves to a DispatchResult { at, id?, provider } so you can correlate the send with the vendor's delivery webhook. Messaging additionally returns requested/actual transports and normalized primary/fallback attempts. The privacy field declares address/content retention preferences for adapters that expose provider retention controls.
// Every channel call returns a DispatchResult you can correlate
// with the vendor's delivery webhook later.
const result = await dispatch.messaging({
content: { kind: 'text', text: 'Database latency is elevated.' },
to: { address: '+15555550123', transport: 'sms' },
tenant: 'acme', // propagates to spans + audit
metadata: { campaign: 'signup' }, // open record adapters interpret
});
console.log(result); // { at: 1785600000000, id: 'SM…', provider: 'twilio', delivery: … }Each vendor adapter is its own npm package — install only the ones you wire.
@absolutejs/dispatch-resendcreateResendAdapter — takes your Resend client; the Resend message id becomes the result id.
@absolutejs/dispatch-postmarkcreatePostmarkAdapter — transactional + broadcast streams; the MessageID becomes the result id.
@absolutejs/dispatch-apnsHTTP/2 APNs delivery with ES256 provider-token rotation, alert/background modes, payload validation, and normalized provider errors.
@absolutejs/dispatch-fcmFCM HTTP v1 delivery with Application Default Credentials, short-lived OAuth tokens, token/topic/condition targets, and platform payloads.
@absolutejs/dispatch-push-postgresTenant-isolated device registry plus fenced, indeterminate-safe fanout claims on PostgreSQL.
@absolutejs/dispatch-aws-end-user-messagingAWS SDK v3, phone-pool RCS fallback, Protect fraud controls, Notify templates, event ingress, readiness, and registration workflows.
@absolutejs/dispatch-infobipMessages API validation, portable rich content, scheduling, authenticated durable callbacks, and US brand/campaign/number operations.
@absolutejs/dispatch-telnyxDirect rich RCS, capability checks, SMS/MMS fallback, Ed25519 webhooks, scheduling, carrier registration, and shared atomic reliability.
@absolutejs/dispatch-twilioRich Messaging Service sending, signed delivery, inbound, and consent webhooks, durable idempotency/lifecycle stores, tenant routing, and API-inspected readiness.
@absolutejs/dispatch-vonageOrdered native failover, rich RCS, capability checks and revocation, signed JWT webhooks, durable reliability, tenant routing, and 10DLC workflows.
@absolutejs/dispatch-sinchConversation API channel-priority fallback, rich transcoding, fast durable HMAC intake, capability lookup, tenant routing, and concrete OAuth 10DLC/toll-free operations.
ClientLike contract and declare that SDK as a peer. AWS ships its exact SDK v3 command clients as adapter dependencies, while Infobip uses the standard fetch contract. No adapter reaches through an undocumented client shape.In-memory and console adapters ship with the core package for tests and local dev:
In-process FIFO buffer (default 1000 messages). Call .inspect() to read a copy, .clear() to reset between tests.
Prints the message as JSON to stdout and returns immediately. Handy for local dev without a vendor account.
Every send is wrapped in a single OpenTelemetry span — dispatch.email.send, dispatch.messaging.send, or dispatch.push.send — carrying these attributes:
dispatcher.metrics() returns cumulative counters since the dispatcher was created:
Pass an { audit } writer shaped like @absolutejs/audit's and every send appends a dispatch.<channel>.sent or dispatch.<channel>.failed event — with the provider and message id in metadata, message.tenant as the actor ('system' when no tenant is set), and the recipient as the target for email/messaging and a safe subscription identifier for push. Raw device tokens are never written to default logs, spans, or audit targets.
Transactional and broadcast streams via messageStream. By default the adapter extracts a tag field from message.metadata into Postmark's Tag (used for analytics segmentation) and routes every other string-valued metadata entry into Postmark's Metadata map — override the mapping with mapMetadata.
import { createPostmarkAdapter } from '@absolutejs/dispatch-postmark';
import { ServerClient } from 'postmark';
const email = createPostmarkAdapter({
client: new ServerClient(process.env.POSTMARK_TOKEN!),
defaultFrom: 'noreply@example.com',
messageStream: 'outbound', // default
});
// Override the default metadata mapping when you need full control
// over Postmark's Tag + Metadata fields:
const emailCustom = createPostmarkAdapter({
client: new ServerClient(process.env.POSTMARK_TOKEN!),
defaultFrom: 'noreply@example.com',
mapMetadata: (metadata) => ({
Tag: typeof metadata.tag === 'string' ? metadata.tag : undefined,
Metadata: {
campaign: String(metadata.campaign),
tenant: String(metadata.tenant),
},
}),
});defaultFrom — otherwise the adapter throws a clear error before the send.Custom headers on the EmailMessage auto-convert to Postmark's [{Name, Value}] array shape. SDK errors propagate, so the dispatcher's onError hook and span error capture kick in.
Use @absolutejs/dispatch-telnyx for SMS, MMS, and direct rich RCS. It maps portable cards and actions to Telnyx RCS, supports explicit SMS/MMS fallback routes and recipient capability checks, and uses the shared atomic reliability package for scoped idempotency and durable webhook recovery.
The webhook handler verifies Telnyx Ed25519 signatures over the raw request, supports bounded public-key rotation, normalizes delivery and interactive inbound events, and can apply STOP/START events to every resolved consent program. Registration and readiness helpers cover 10DLC, toll-free, Messaging Profile binding, and explicit RCS approval.
import { createDispatcher } from '@absolutejs/dispatch';
import {
createPostgresIdempotentOperationStore,
createPostgresTransactionRunner,
createPostgresWebhookInboxStore,
createTelnyxAdapter,
createTelnyxWebhookHandler,
} from '@absolutejs/dispatch-telnyx';
import { Telnyx } from 'telnyx';
const client = new Telnyx({ apiKey: process.env.TELNYX_API_KEY });
const runner = createPostgresTransactionRunner(postgresPool);
const messaging = createTelnyxAdapter({
accountId: process.env.TELNYX_ORGANIZATION_ID,
client,
idempotencyStore: createPostgresIdempotentOperationStore(runner),
messagingProfileId: process.env.TELNYX_MESSAGING_PROFILE_ID,
rcsAgentId: process.env.TELNYX_RCS_AGENT_ID,
webhookUrl: 'https://example.com/webhooks/telnyx',
});
const telnyxWebhook = createTelnyxWebhookHandler({
handler: event => lifecycle.record(event),
inbox: createPostgresWebhookInboxStore(runner),
resolveAccount: organizationId => telnyxWebhookAccount(organizationId),
resolveConsentScopes: event => programsForNumber(event.from),
});
app.post('/webhooks/telnyx', ({ request }) => telnyxWebhook(request));
const dispatch = createDispatcher({ messaging });
await dispatch.messaging({
content: {
kind: 'rich',
title: 'Production alert',
text: 'Database latency is elevated.',
actions: [{
kind: 'url',
label: 'Open incident',
url: 'https://example.com/incidents/42',
}],
},
consent: { programId: 'pro-alerts', purpose: 'incident-alerts' },
fallbacks: [{ transport: 'sms' }],
idempotencyKey: 'incident-42:recipient-7',
to: { address: '+12025550100', transport: 'rcs' },
});Use @absolutejs/dispatch-twilio for application-authored SMS alerts, MMS, RCS with SMS/MMS fallback, WhatsApp, and Twilio Content templates. Use @absolutejs/auth-twilio for Verify-managed OTP, MFA, recovery, and step-up challenges. Twilio voice calls and Media Streams remain in @absolutejs/voice; they are not messaging or auth adapters.
Messaging requires a Twilio Messaging Service and HTTPS status callback. The signed webhook handler normalizes delivery states, ordinary inbound replies and media, plus Advanced Opt-Out STOP, START, and HELP events. Its atomic lifecycle-store contract deduplicates retries and rejects stale status transitions. Signed START/STOP events can update the provider-neutral consent ledger, whose dispatch policy blocks missing or revoked consent before a provider call.
import {
createTwilioComplianceManager,
createPostgresTwilioIdempotencyStore,
createTwilioAdapter,
createTwilioWebhookHandler,
inspectTwilioMessagingReadiness,
} from '@absolutejs/dispatch-twilio';
import {
createMessagingConsentDispatchPolicy,
createMessagingConsentLedger,
createPostgresMessagingConsentStore,
} from '@absolutejs/compliance';
import { createDispatcher } from '@absolutejs/dispatch';
import { Twilio } from 'twilio';
const client = new Twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
const consent = createMessagingConsentLedger({
store: createPostgresMessagingConsentStore(postgres),
});
const messaging = createTwilioAdapter({
accountSid: process.env.TWILIO_SID,
client,
idempotencyStore: createPostgresTwilioIdempotencyStore(postgres),
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
statusCallbackUrl: 'https://example.com/webhooks/twilio/messaging',
validityPeriod: 300,
});
const webhook = createTwilioWebhookHandler({
resolveAccount: accountSid => twilioWebhookAccount(accountSid),
publicUrl: 'https://example.com/webhooks/twilio/messaging',
lifecycleStore: durableLifecycleStore,
consentLedger: consent,
resolveScopes: event => [{
programId: 'acme-alerts',
purpose: 'incident-alerts',
tenant: 'tenant-a',
}],
onEvent: event => lifecycle.record(event),
});
app.post('/webhooks/twilio/messaging', ({ request }) => webhook(request));
const dispatch = createDispatcher({
policies: [createMessagingConsentDispatchPolicy({ ledger: consent })],
messaging,
});
await dispatch.messaging({
content: { kind: 'text', text: 'Service health has degraded.' },
consent: { programId: 'acme-alerts', purpose: 'incident-alerts' },
fallbacks: [{
from: { address: '+12025550199', transport: 'sms' },
transport: 'sms',
}],
tenant: 'tenant-a',
to: { address: '+12025550100', transport: 'rcs' },
});
const registration = createTwilioComplianceManager(client);
const registrationStatus = await registration.inspect({
kind: 'a2p',
customerProfileSid,
brandRegistrationSid,
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
campaignSid,
});
const readiness = await inspectTwilioMessagingReadiness({
client,
expectedAccountSid: process.env.TWILIO_SID,
inboundWebhookUrl: 'https://example.com/webhooks/twilio/inbound',
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
requiresUsA2PRegistration: true,
requiresRcsSender: true,
statusCallbackUrl: 'https://example.com/webhooks/twilio/messaging',
store: durableLifecycleStore,
assertions: {
consentEvidenceStored: true,
optOutConfigured: true,
privacyPolicyPublished: true,
termsPublished: true,
},
});Use @absolutejs/dispatch-vonage for SMS, MMS, RCS, WhatsApp, Viber, and Messenger through the Messages API. The adapter supports ordered provider-native failover, rich RCS cards and actions, WhatsApp templates, multi-account tenant routing, and scoped atomic idempotency.
Signed JWT webhooks are validated against the exact raw body and normalized into shared delivery, inbound, interaction, and STOP/START consent events. Operational helpers cover application readiness, complete 10DLC brand/campaign/number workflows, RCS device capabilities and revocation, and WhatsApp read receipts.
import { createDispatcher } from '@absolutejs/dispatch';
import {
createPostgresIdempotentOperationStore,
createPostgresTransactionRunner,
createPostgresWebhookInboxStore,
createVonageAdapter,
createVonageWebhookHandler,
} from '@absolutejs/dispatch-vonage';
import { Vonage } from '@vonage/server-sdk';
const client = new Vonage({
applicationId: process.env.VONAGE_APPLICATION_ID!,
privateKey: process.env.VONAGE_PRIVATE_KEY!,
});
const runner = createPostgresTransactionRunner(postgresPool);
const messaging = createVonageAdapter({
apiKey: process.env.VONAGE_API_KEY!,
client,
defaultFrom: {
rcs: process.env.VONAGE_RCS_AGENT_ID!,
sms: process.env.VONAGE_SMS_NUMBER!,
},
idempotencyStore: createPostgresIdempotentOperationStore(runner),
});
const webhook = createVonageWebhookHandler({
handler: event => lifecycle.record(event),
inbox: createPostgresWebhookInboxStore(runner),
resolveAccount: apiKey => vonageWebhookAccount(apiKey),
resolveConsentScopes: event => programsForNumber(event.from),
});
app.post('/webhooks/vonage', ({ request }) => webhook(request));
const dispatch = createDispatcher({ messaging });
await dispatch.messaging({
content: { kind: 'text', text: 'Database latency is elevated.' },
consent: { programId: 'pro-alerts', purpose: 'incident-alerts' },
fallbacks: [{ transport: 'sms' }],
idempotencyKey: 'incident-42:recipient-7',
to: { address: '+12025550100', transport: 'rcs' },
});Use @absolutejs/dispatch-sinch with Sinch's recommended Conversation API for SMS, MMS, RCS, WhatsApp, Viber Business, Messenger, Instagram, Telegram, KakaoTalk, LINE, and WeChat. One portable message is transcoded across an ordered channel-priority fallback route.
The adapter resolves app-scoped social identities explicitly, verifies HMAC callbacks over the exact raw body, persists callbacks atomically with stable retry identifiers, and normalizes delivery, inbound, choice, capability, provider opt-in/out, WhatsApp preference, and SMS STOP/START events. Operational helpers cover live app/webhook readiness, asynchronous channel capabilities, plus a concrete OAuth client for 10DLC, number linking, and toll-free verification. HTTP intake returns after durable storage; consent and application effects run through the retryable drain.
import { createDispatcher } from '@absolutejs/dispatch';
import {
createPostgresIdempotentOperationStore,
createPostgresTransactionRunner,
createPostgresWebhookInboxStore,
createSinchAdapter,
createSinchWebhookHandler,
drainSinchWebhookInbox,
} from '@absolutejs/dispatch-sinch';
import {
createMessagingConsentLedger,
createPostgresMessagingConsentStore,
} from '@absolutejs/compliance';
import { SinchClient } from '@sinch/sdk-core';
const client = new SinchClient({
conversationRegion: 'us',
keyId: process.env.SINCH_KEY_ID!,
keySecret: process.env.SINCH_KEY_SECRET!,
projectId: process.env.SINCH_PROJECT_ID!,
});
const runner = createPostgresTransactionRunner(postgresPool);
const inbox = createPostgresWebhookInboxStore(runner);
const consentLedger = createMessagingConsentLedger({
store: createPostgresMessagingConsentStore(postgresPool),
});
const messaging = createSinchAdapter({
appId: process.env.SINCH_APP_ID!,
client,
idempotencyStore: createPostgresIdempotentOperationStore(runner),
projectId: process.env.SINCH_PROJECT_ID!,
resolveRecipientIdentity: ({ address, transport }) =>
transport === 'messenger' ? lookupMessengerPsid(address) : address,
});
const webhook = createSinchWebhookHandler({
inbox,
resolveAccount: accountKey => sinchWebhookAccount(accountKey),
resolveAccountKey: ({ url }) => new URL(url).pathname.split('/').at(-1)!,
});
app.post('/webhooks/sinch/:accountKey', ({ request }) => webhook(request));
// Run from a worker. Intake returns 202 before these retryable effects run.
await drainSinchWebhookInbox({
consentLedger,
handler: event => lifecycle.record(event),
inbox,
resolveConsentScopes: event => programsForNumber(event.from),
});
const dispatch = createDispatcher({ messaging });
await dispatch.messaging({
content: { kind: 'text', text: 'Database latency is elevated.' },
consent: { programId: 'pro-alerts', purpose: 'incident-alerts' },
fallbacks: [{ transport: 'sms' }],
idempotencyKey: 'incident-42:recipient-7',
to: { address: '+12025550100', transport: 'rcs' },
});No vendor mocks needed. The in-memory adapters keep an in-process FIFO buffer — call .inspect() to assert what would have shipped, .clear() to reset between tests.
import { createDispatcher, memoryEmailAdapter } from '@absolutejs/dispatch';
const email = memoryEmailAdapter();
const dispatch = createDispatcher({ email });
await dispatch.email({
to: 'a@b.c',
subject: 'hi',
text: 'hi',
});
const sent = email.inspect();
expect(sent).toHaveLength(1);
expect(sent[0].subject).toBe('hi');
// Between tests:
email.clear();
expect(email.inspect()).toHaveLength(0);For error-path tests, wrap an adapter and have its .send() reject — the dispatcher's failed counter, span error, audit failure event, and onError hook all fire on the same rejection.
These playbooks show where this package fits, how to verify the combined system, and what changes before production.
Current package surface
Import surface · click to copy
Optional reference to @absolutejs/audit. We don't import the type directly — that would force consumers to install audit even when unused. We accept anything structurally compatible with the minimal append shape.
type AuditLike = {
append: (event: {
kind: string;
actor?: string;
target?: string;
metadata?: Record<string, unknown>;
}) => Promise<void>;
};@absolutejs/dispatchOutcomes
Provider-agnostic outbound message dispatcher for the AbsoluteJS ecosystem.
createDispatcher(options)
OpenTelemetry
Hardening checklist
Follow in order