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.
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.
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
Bot API, inline keyboards, and channel posting via @name
Cloud Business API; approved MARKETING templates for campaigns
Messenger
Meta Send API, plus publishPost() to the Page feed
Direct messages, plus publishPost() for feed posts and Reels
LINE
broadcast() to all friends, multicast() to a segment
Discord
HTTP Interactions with buttons and message components
Microsoft Teams
Bot Framework channel with Adaptive Card support
Slack
Events API and Block Kit; posts to a channel, not to users
massSend() to all followers or a tag group, quota-metered
Viber
broadcast() to up to 300 subscribers per call
Mattermost
Self-hosted chat over REST and outgoing webhooks
Rocket.Chat
Self-hosted chat via REST plus outgoing webhooks
Google Chat
Service-account auth with Google-signed webhooks
Gmail
Pub/Sub push delivery and MIME attachment handling
Outlook / M365
Graph change notifications and attachment handling
SMTP / IMAP
Yahoo, Zoho, Fastmail or any custom mail server
Resend
Transactional email over HTTP, Edge-compatible
SendGrid
Inbound Parse and ECDSA-signed event webhooks
Amazon SES
High-volume email, SigV4 with SNS bounce handling
Twilio SMS
SMS and MMS, with media attachments and receipts
Exotel
India-focused SMS with DLT template compliance
MSG91
India SMS through the DLT Flow API, template-first
Vonage
Global SMS delivery with signed inbound webhooks
Plivo
Global SMS and MMS with V3 signature verification
Telnyx
Global SMS and MMS, Ed25519-signed webhooks
Twilio Voice
TwiML flows, Gather input and outbound calls
Subreddit posts, thread replies and inbox polling
FCM
Push to Android, iOS and web, plus topic broadcast
Core
Hub, adapter contract, retries, storage, campaigns
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.
Install
Only the channels you actually need.
npm install @msgly/core @msgly/telegramGet 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-stringRegister 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 });Go live
Expose port 3000 with ngrok, register the webhook URL with Telegram, and send your bot a message.
ngrok http 3000Features 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.
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
SES, SMTP, Resend, SendGrid, Twilio SMS, Exotel, MSG91, Vonage, Plivo, Telnyx, FCM
Email, SMS and push, fanned out per recipient. Honour opt-outs.
LINE, WeChat, Viber, Telegram, FCM topics
One API call reaches the whole audience — no per-recipient fan-out.
Instagram, Facebook Pages, Reddit
publishPost() puts a post on the feed. No recipient, so it sits outside send().
Real campaign channel, but needs approved MARKETING templates and opt-in.
Messenger, Instagram DMs
24h window and message tags only — no DM marketing broadcast.
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.
// 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.
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 outArchitecture
Three layers, clean contracts.
Adding a new channel is one new package — no core changes needed.
Express, Fastify, Next.js route handlers, a worker — anything that can receive an HTTP POST.
Unified types, the MessagingHub orchestrator, retries, idempotency, capability checks, rate limiting, storage and suppression.
One package per platform. Each implements the same Adapter interface and ships its own verifyCredentials().
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