AbsoluteJS

Dispatch

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.

#Channel and provider are separate decisions

Choose the application-level channel first; provider adapters remain replaceable beneath the same policy and evidence boundary.

OptionUse whenPortable contentTypical adapters
EmailRich transactional or lifecycle communicationSubject, text/HTML, sender, recipients, headersPostmark, AWS SES, Resend
MessagingSMS, MMS, RCS, WhatsApp, and social conversationsContent, fallbacks, consent, privacy, scheduleTwilio, Telnyx, Vonage, Sinch, Infobip
PushDevice notifications and deep-link re-engagementTitle, body, data, actions, badge, soundAPNs, FCM

#From application intent to durable provider evidence

One send produces a consistent policy, provider, observability, and callback trail across every adapter.

  1. Normalize
    Normalize the typed message and derive tenant, consent, privacy, and idempotency context.
  2. Authorize
    Run ordered application policies before revealing content to a provider.
  3. Deliver
    Translate through the selected channel adapter and invoke the provider.
  4. Evidence
    Emit a typed result, metrics, trace span, and audit event together.
  5. Reconcile
    Verify, persist, deduplicate, and drain provider callbacks into normalized events.

#Install

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.

BASH
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 twilio

#Quick Start

Runnable · zero credentials
Run this with only @absolutejs/dispatch installed. Success is proved by provider === memory and one captured message from email.inspect().

createDispatcher() 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.

TS
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 message

#Production model

The 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/dispatchApplication-authored email, carrier/rich messaging, and push. It owns typed messages, policy evaluation, results, metrics, tracing, and audit emission.
@absolutejs/complianceProvider-neutral consent evidence and a pre-send policy that rejects missing or revoked recipient/program/purpose/transport scopes.
@absolutejs/reliabilityDurable webhook inboxes, checked-out PostgreSQL transactions, and fenced idempotent operations shared by provider adapters.
@absolutejs/auth-twilioProvider-owned OTP generation, delivery, fraud checks, and code verification through Twilio Verify. Auth owns enrollment and session promotion.
@absolutejs/voiceTwilio voice calls and Media Streams. Voice is intentionally outside Dispatch messaging and Auth verification.
Alerts and OTP are different products
Use @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.

TS
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' },
});
Registration is not consent
10DLC, toll-free, RCS, WhatsApp, and sender approval make a traffic program eligible for a carrier channel. They do not replace recipient-level evidence, opt-out handling, privacy terms, or the application's legal review.

#Durable webhook lifecycle

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.

1
Verify
Authenticate the exact raw request using the provider signature, JWT, HMAC, or the trusted gateway/EventBridge boundary.
2
Persist
Write the raw payload and stable provider event id to a durable WebhookInboxStore before performing application effects.
3
Acknowledge
Return 202 as soon as durable intake succeeds so slow consent, lifecycle, and application work cannot trigger provider retry storms.
4
Drain
Claim with a fencing token, normalize into delivery/inbound/consent events, apply idempotent effects in a worker, then complete or release.
deliveryNormalized provider status, requested/actual transport, attempt history, failure detail, carrier/economics metadata when available.
inboundTyped sender/recipient endpoints, portable text/media content, and interaction payloads for replies and rich actions.
consentGrant, revoke, or help intent from provider opt-in/opt-out signals; adapters can apply these to the shared consent ledger.

#Push lifecycle

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.

TS
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);
}
deliveredThe provider accepted the send and the claim completed.
retiredThe provider reported an invalid token; the registration was disabled.
skippedA completed or in-flight fenced claim already owns this fanout item.
failedA definite failure exhausted bounded retries; a fresh operation may decide whether to resend.
indeterminateThe provider acknowledgement was ambiguous. Reconcile it operationally; never convert it into an automatic duplicate send.

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 and FCM

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.

TS
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();

#AWS End User Messaging

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.

TS
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.

TS
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

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.

TS
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.

TS
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));

#Channels

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.

Email

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:

email / messaging / pushOne optional adapter per channel. Only the channels you configure become callable.
defaultFromFallback sender per channel ({ email?, messaging? }) when a message omits from.
policiesOrdered synchronous or asynchronous authorization checks that run before any adapter or provider receives the message.
auditAudit writer from @absolutejs/audit — appends a sent/failed event for every send.
tracerProviderOpenTelemetry TracerProvider (via @absolutejs/telemetry) — one span per send.
onError(err, channel, message) => void hook that fires on every failed send.

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.

TS
// 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:}

#Adapters

Each vendor adapter is its own npm package — install only the ones you wire.

dispatch-resendv0.7.0Email
@absolutejs/dispatch-resend

createResendAdapter — takes your Resend client; the Resend message id becomes the result id.

dispatch-postmarkv0.1.0Email
@absolutejs/dispatch-postmark

createPostmarkAdapter — transactional + broadcast streams; the MessageID becomes the result id.

dispatch-apnsv0.2.0Apple push
@absolutejs/dispatch-apns

HTTP/2 APNs delivery with ES256 provider-token rotation, alert/background modes, payload validation, and normalized provider errors.

dispatch-fcmv0.2.0Android / Apple / web push
@absolutejs/dispatch-fcm

FCM HTTP v1 delivery with Application Default Credentials, short-lived OAuth tokens, token/topic/condition targets, and platform payloads.

dispatch-push-postgresv0.1.0Push lifecycle storage
@absolutejs/dispatch-push-postgres

Tenant-isolated device registry plus fenced, indeterminate-safe fanout claims on PostgreSQL.

dispatch-aws-end-user-messagingv0.1.0SMS / MMS / RCS / WhatsApp
@absolutejs/dispatch-aws-end-user-messaging

AWS SDK v3, phone-pool RCS fallback, Protect fraud controls, Notify templates, event ingress, readiness, and registration workflows.

dispatch-infobipv0.1.0Global carrier / conversational messaging
@absolutejs/dispatch-infobip

Messages API validation, portable rich content, scheduling, authenticated durable callbacks, and US brand/campaign/number operations.

dispatch-telnyxv0.3.0SMS / MMS / RCS
@absolutejs/dispatch-telnyx

Direct rich RCS, capability checks, SMS/MMS fallback, Ed25519 webhooks, scheduling, carrier registration, and shared atomic reliability.

dispatch-twiliov0.7.0SMS / MMS / RCS / WhatsApp
@absolutejs/dispatch-twilio

Rich Messaging Service sending, signed delivery, inbound, and consent webhooks, durable idempotency/lifecycle stores, tenant routing, and API-inspected readiness.

dispatch-vonagev0.2.0SMS / MMS / RCS / WhatsApp / Viber / Messenger
@absolutejs/dispatch-vonage

Ordered native failover, rich RCS, capability checks and revocation, signed JWT webhooks, durable reliability, tenant routing, and 10DLC workflows.

dispatch-sinchv0.3.0SMS / MMS / RCS / WhatsApp / social messaging
@absolutejs/dispatch-sinch

Conversation API channel-priority fallback, rich transcoding, fast durable HMAC intake, capability lookup, tenant routing, and concrete OAuth 10DLC/toll-free operations.

Provider SDK ownership is explicit
Adapters that expect an application-owned SDK expose a narrow 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:

memoryEmailAdapter / memoryMessagingAdapter / memoryPushAdapter

In-process FIFO buffer (default 1000 messages). Call .inspect() to read a copy, .clear() to reset between tests.

consoleEmailAdapter / consoleMessagingAdapter / consolePushAdapter

Prints the message as JSON to stdout and returns immediately. Handy for local dev without a vendor account.

#Observability

Every send is wrapped in a single OpenTelemetry span — dispatch.email.send, dispatch.messaging.send, or dispatch.push.send — carrying these attributes:

dispatch.channel'email' | 'messaging' | 'push'
dispatch.providerAdapter name — 'resend', 'postmark', 'twilio', …
dispatch.recipient_countRecipient count only; addresses and device tokens are excluded.
dispatch.message_idVendor id, set after the adapter returns one
abs.tenantmessage.tenant when set

dispatcher.metrics() returns cumulative counters since the dispatcher was created:

sentCumulative successful sends across every channel.
failedCumulative failed sends across every channel.
byChannelPer-channel { sent, failed } breakdown for email, messaging, and push.

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.

#Postmark

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.

TS
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),
    },
  }),
});
Postmark requires a From address
Pass it per-message or via 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.

#Telnyx

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.

TS
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' },
});

#Twilio

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.

TS
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,
  },
});
Production readiness
The readiness report inspects the real account/service binding, callbacks, sender pool, optional RCS sender, and US A2P attachment. The compliance manager submits A2P brand/campaign and toll-free verification requests and inspects their live status. Consent evidence, opt-out testing, privacy, and messaging terms remain operator responsibilities. Reports are operational, not legal certification.

#Vonage

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.

TS
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' },
});

#Sinch

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.

TS
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' },
});

#Testing

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.

TS
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.

Continue toward an outcome

These playbooks show where this package fits, how to verify the combined system, and what changes before production.

Current package surface

What ships today

@absolutejs/dispatchv0.7.1 · betaMessagingnpmSource
3entry points69symbols

Import surface · click to copy

68 symbols
AuditLiketypePermalinkSource

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.

TS
type AuditLike = {
    append: (event: {
        kind: string;
        actor?: string;
        target?: string;
        metadata?: Record<string, unknown>;
    }) => Promise<void>;
};
Exported from @absolutejs/dispatch

Outcomes

What you can build

Overview

Provider-agnostic outbound message dispatcher for the AbsoluteJS ecosystem.

API

createDispatcher(options)

Substrate pattern

OpenTelemetry

Hardening checklist

Production guidance

Bundled adaptersThese ship in core for tests + dev. Production deployments use the sibling vendor adapters.

Follow in order

Troubleshooting path

1
Trace from the first failed boundary
Reproduce the smallest canonical @absolutejs/dispatch example, confirm the supported entry point and version in the API explorer, then inspect the first boundary that did not produce its documented result.