MIT licensed · TypeScript-native · Node 20+

One API for
every messaging channel.

Msgly collapses 28 platforms — chat, email, SMS, push and voice — into a single unified interface. Register the adapters you need, send and receive in one format, and stop learning a new webhook payload every quarter.

0channel adapters
0unified message type
0core changes to add a channel
MITlicence

Why msgly

Multi-channel shouldn't mean multi-codebase.

Building a chatbot or notification system that works across channels means learning many different APIs, webhook formats, signature schemes and media rules. Msgly gives you one TypeScript-native interface over all of them — with retries, idempotency, capability checks and rate limiting already handled.

Channel names are open: ChannelName accepts any string, so you can publish a third-party adapter without waiting on a core release. Built-in names keep editor autocomplete.

echo-bot.ts
import express from 'express';
import { createHub } from '@msgly/core';
import { createTelegramAdapter } from '@msgly/telegram';
import { createWhatsAppAdapter } from '@msgly/whatsapp';

const hub = createHub();

hub.register(createTelegramAdapter({
  botToken: process.env.TELEGRAM_BOT_TOKEN!,
  webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
}));

hub.register(createWhatsAppAdapter({
  phoneNumberId: process.env.WA_PHONE_ID!,
  accessToken: process.env.WA_TOKEN!,
  appSecret: process.env.META_APP_SECRET!,
  verifyToken: process.env.META_VERIFY_TOKEN!,
}));

// Verify credentials at startup — fail fast on bad tokens
await hub.connect({ throwOnFailure: true });

hub.on('message', async (msg) => {
  if (msg.content.type === 'text') {
    await hub.send({
      channel: msg.channel,
      account: msg.account,
      contact: msg.contact,
      content: { type: 'text', text: `You said: ${msg.content.text}` },
    });
  }
});

const app = express();
const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);
app.listen(3000);

Every module

28 adapters, one contract.

Each channel is its own package, so you install only what you ship. Every adapter implements the same Adapter interface and brings its own credential check.

Telegram

@msgly/telegram

Bot API, inline keyboards, and channel posting via @name

WhatsApp

@msgly/whatsapp
Policy-gated

Cloud Business API; approved MARKETING templates for campaigns

Messenger

@msgly/messenger

Meta Send API, plus publishPost() to the Page feed

Instagram

@msgly/instagram

Direct messages, plus publishPost() for feed posts and Reels

LINE

@msgly/line

broadcast() to all friends, multicast() to a segment

Discord

@msgly/discord
No campaigns

HTTP Interactions with buttons and message components

Microsoft Teams

@msgly/msteams
No campaigns

Bot Framework channel with Adaptive Card support

Slack

@msgly/slack
No campaigns

Events API and Block Kit; posts to a channel, not to users

WeChat

@msgly/wechat

massSend() to all followers or a tag group, quota-metered

Viber

@msgly/viber

broadcast() to up to 300 subscribers per call

Mattermost

@msgly/mattermost
No campaigns

Self-hosted chat over REST and outgoing webhooks

Rocket.Chat

@msgly/rocketchat
No campaigns

Self-hosted chat via REST plus outgoing webhooks

Google Chat

@msgly/googlechat
No campaigns

Service-account auth with Google-signed webhooks

Gmail

@msgly/gmail

Pub/Sub push delivery and MIME attachment handling

Outlook / M365

@msgly/outlook

Graph change notifications and attachment handling

SMTP / IMAP

@msgly/smtp
Outbound

Yahoo, Zoho, Fastmail or any custom mail server

Resend

@msgly/resend
Outbound

Transactional email over HTTP, Edge-compatible

SendGrid

@msgly/sendgrid
Outbound

Inbound Parse and ECDSA-signed event webhooks

Amazon SES

@msgly/ses
Outbound

High-volume email, SigV4 with SNS bounce handling

Twilio SMS

@msgly/twilio-sms
Outbound

SMS and MMS, with media attachments and receipts

Exotel

@msgly/exotel
Outbound

India-focused SMS with DLT template compliance

MSG91

@msgly/msg91
Outbound

India SMS through the DLT Flow API, template-first

Vonage

@msgly/vonage-sms
Outbound

Global SMS delivery with signed inbound webhooks

Plivo

@msgly/plivo
Outbound

Global SMS and MMS with V3 signature verification

Telnyx

@msgly/telnyx
Outbound

Global SMS and MMS, Ed25519-signed webhooks

Twilio Voice

@msgly/twilio-voice

TwiML flows, Gather input and outbound calls

Reddit

@msgly/reddit

Subreddit posts, thread replies and inbox polling

FCM

@msgly/fcm
Outbound

Push to Android, iOS and web, plus topic broadcast

Core

@msgly/core

Hub, adapter contract, retries, storage, campaigns

No adapter for LinkedIn, X/Twitter DM or iMessage.

None of them has a usable API for this — LinkedIn's messaging API is partner-gated, and automating the web UI violates their terms and gets accounts banned.

60-second quickstart

From zero to a live bot.

Start with Telegram — the easiest channel. No business verification, no Meta App, no Pages. You need Node.js 20+ and a Telegram account.

01

Install

Only the channels you actually need.

npm install @msgly/core @msgly/telegram
02

Get a bot token

Message @BotFather on Telegram, send /newbot, copy the token. No business verification, no Meta App.

TELEGRAM_BOT_TOKEN=123456789:ABC-DEF...
TELEGRAM_WEBHOOK_SECRET=any-random-string
03

Register and connect

Credentials are verified at boot, so bad tokens fail fast with an actionable hint.

const hub = createHub();
hub.register(createTelegramAdapter({
  botToken: process.env.TELEGRAM_BOT_TOKEN!,
  webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET!,
}));
await hub.connect({ throwOnFailure: true });
04

Go live

Expose port 3000 with ngrok, register the webhook URL with Telegram, and send your bot a message.

ngrok http 3000

Features in detail

The unglamorous parts, already done.

Signature verification, exponential backoff, duplicate webhooks, per-platform size caps — the work that makes a messaging integration production-ready rather than demo-ready.

Startup credentials check

Every adapter ships verifyCredentials(). hub.connect() calls the platform whoami endpoint for each channel and returns either confirmation or a precise hint — which env var, where to find it, how to regenerate.

const report = await hub.connect();
// { telegram: { ok: true, accountInfo: '@my_bot' },
//   whatsapp: { ok: false, reason: 'unauthorized', hint: '...' } }

// Or fail-fast for boot scripts:
await hub.connect({ throwOnFailure: true });

One webhook handler, every channel

hub.createWebhookHandler() returns { get, post } for any Express-like framework: Meta GET handshake, per-platform HMAC signature verification, channel dispatch and idempotent de-duplication by externalId.

const handlers = hub.createWebhookHandler();
app.get('/webhook/:channel', handlers.get);
app.post('/webhook/:channel', handlers.post);

Smart retry

Transient failures retry with exponential backoff and jitter. Permanent failures — bad credentials, invalid recipients — fail immediately instead of burning your rate limit.

const hub = createHub({
  retry: { attempts: 4, baseDelayMs: 250 },
});

State persistence

Bring any KV store — Redis, DynamoDB, Cloudflare KV. The hub uses it for idempotency keys, conversation state and suppression, so restarts and multiple instances stay consistent.

const hub = createHub({
  storage: redisStorage(redisClient),
});

Capability checks

Ask before you send. Channels differ on attachments, buttons, templates and reactions — msgly answers up front rather than failing at the API boundary.

if (hub.supports('telegram', 'reaction')) {
  await hub.react({ /* ... */ });
}

Platform limits enforced

Text length caps, attachment size and MIME restrictions are validated locally before the request goes out, so you get a clear error instead of an opaque platform rejection.

// 4096 chars on Telegram, 1600 on SMS —
// checked before the network call.

Campaigns

Send to many, without getting throttled.

hub.sendBulk() fans one message out to a contact list, paced to the channel's rate limit. One bad recipient never aborts the campaign: it resolves rather than rejecting, and result.results comes back in input order so you can zip it against your own list.

Conservative per-channel defaults ship built in (Slack 1/s, Twilio long code 1/s, Gmail ~2/s, Discord 5/s, Telegram 25/s, WhatsApp 60/s), overridable per call, per adapter or per hub. Cancelling via signal gives you partial results instead of throwing them away.

campaign.ts
const result = await hub.sendBulk({
  channel: 'whatsapp',
  account: { channel: 'whatsapp', channelAccountId: process.env.WA_PHONE_ID! },
  recipients: customers.map((c) => ({
    contact: { channel: 'whatsapp', channelUserId: c.phone },
    metadata: { crmId: c.id },
  })),
  // A function, so every recipient gets their own template variables:
  content: (r) => ({
    type: 'template',
    templateName: 'order_update',
    language: 'en_US',
    variables: { '1': nameFor(r.contact), '2': orderFor(r.contact) },
  }),
  concurrency: 8,
  onProgress: (p) => console.log(`${p.completed}/${p.total}`),
  signal: AbortSignal.timeout(60_000),
});

console.log(`sent ${result.sent}, failed ${result.failed}`);

Which channels suit campaigns

Built for outbound

SES, SMTP, Resend, SendGrid, Twilio SMS, Exotel, MSG91, Vonage, Plivo, Telnyx, FCM

Email, SMS and push, fanned out per recipient. Honour opt-outs.

Native broadcast

LINE, WeChat, Viber, Telegram, FCM topics

One API call reaches the whole audience — no per-recipient fan-out.

Feed publishing

Instagram, Facebook Pages, Reddit

publishPost() puts a post on the feed. No recipient, so it sits outside send().

Policy-gated

WhatsApp

Real campaign channel, but needs approved MARKETING templates and opt-in.

Reply-only DMs

Messenger, Instagram DMs

24h window and message tags only — no DM marketing broadcast.

Not campaign channels

Slack, Teams, Discord, Mattermost, Rocket.Chat, Google Chat

The recipient is a room, not a person. Post to a channel instead.

Broadcast

Some channels don't need fan-out at all.

LINE, WeChat, Viber and Telegram have a real broadcast primitive: one API call reaches the entire audience, with no per-recipient cost and no rate-limit pacing to worry about. Reaching 100,000 LINE friends is a single request, not 100,000 of them.

Instagram and Facebook are different again — publishPost() puts a post on the feed. A post has no recipient, so it deliberately sits outside send() rather than pretending to be a message.

broadcast.ts
// LINE: one call reaches every friend — no fan-out, no per-recipient cost
await line.broadcast(
  { type: 'text', text: 'Sale starts now' },
  { retryKey: 'spring-sale-2026' },   // a timeout cannot double-send
);

// WeChat: all followers, or one tag group. Metered at 4/month.
await wechat.massSend({ type: 'text', text: 'New arrivals' }, { tagId: 7 });

// Viber: up to 300 subscribers per call, with per-recipient failures returned
const r = await viber.broadcast(ids, { type: 'text', text: 'Sale' });
r.metadata?.failed; // [{ id, status }] — feed these to the suppression store

// Telegram: a channel is just another chat id
await hub.send({
  channel: 'telegram',
  contact: { channel: 'telegram', channelUserId: '@acme_announcements' },
  content: { type: 'text', text: 'Shipped v2' },
  account: { channel: 'telegram', channelAccountId: 'acme_bot' },
});

// Instagram / Facebook: publishing, not messaging
await instagram.publishPost({
  imageUrl: 'https://cdn.acme.com/promo.jpg',
  caption: 'Spring sale is live',
});

Compliance

Opt-outs are not optional.

sendBulk consults a SuppressionStore before every send — required by TCPA and TRAI/DLT for SMS, and CAN-SPAM and GDPR for email. Suppressed recipients come back as skipped, never failed, and cost no rate limit.

If the store is unreachable the send is skipped rather than sent: not sending is the recoverable mistake. Only permanent failures suppress — a deferral or a full mailbox is left alone. Email adapters also emit the List-Unsubscribe header Gmail and Yahoo have required from bulk senders since February 2024.

suppression.ts
import { createHub, createInMemorySuppressionStore, applyConsentIntent } from '@msgly/core';

const suppression = createInMemorySuppressionStore(); // use a KV store in production
const hub = createHub({ suppressionStore: suppression });

// Capture STOP / UNSUBSCRIBE replies automatically
hub.on('message', (msg) => applyConsentIntent(msg, suppression));

// Hard bounces and spam complaints suppress themselves
hub.on('delivery', (r) => applyDeliveryReceipt(r, 'resend', suppression));

const result = await hub.sendBulk({ /* ... */ });
console.log(result.sent, result.skipped); // skipped = opted out

Architecture

Three layers, clean contracts.

Adding a new channel is one new package — no core changes needed.

Your app

Express, Fastify, Next.js route handlers, a worker — anything that can receive an HTTP POST.

@msgly/core

Unified types, the MessagingHub orchestrator, retries, idempotency, capability checks, rate limiting, storage and suppression.

Channel adapters

One package per platform. Each implements the same Adapter interface and ships its own verifyCredentials().

Platform APIs

Telegram, Meta, LINE, Twilio, Google, Microsoft, AWS, Firebase and the rest.

Install

Ship your first channel today.

Install only the channels you need. Everything is MIT licensed and TypeScript-native.

npm install @msgly/core @msgly/whatsapp @msgly/telegram @msgly/twilio-sms