Zernio
One adapter, seven platforms. Reach Instagram, Facebook, Twitter/X, Telegram, WhatsApp, Bluesky, and Reddit through Zernio without managing each platform's developer program, app review, or token rotation.
Install
pnpm add @zernio/chat-sdk-adapter chat @chat-adapter/state-memoryFor production, swap @chat-adapter/state-memory for a persistent state adapter such as @chat-adapter/state-redis or @chat-adapter/state-pg. See State Adapters for all options.
Quick start
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createZernioAdapter } from "@zernio/chat-sdk-adapter";
export const bot = new Chat({
userName: "pizza-bot",
adapters: {
zernio: createZernioAdapter(),
},
state: createMemoryState(),
});
// Pattern is a RegExp — `/.*/ ` matches every message.
bot.onNewMessage(/.*/, async (thread, message) => {
const platform = (message.raw as { platform: string }).platform;
await thread.post(`Hello from ${platform}!`);
});import { bot } from "@/lib/bot";
export async function POST(request: Request) {
return bot.webhooks.zernio(request);
}Why Zernio
Even with native Chat SDK adapters for each platform, shipping a multi-platform bot still means applying to Meta's developer program, going through App Review, getting WhatsApp Business verification, applying for X elevated access, and managing token rotation across all of them. Zernio replaces that with a dashboard where users connect their own accounts and you get one API key.
Configuration
Environment variables
| Variable | Required | Description |
|---|---|---|
ZERNIO_API_KEY | Yes | Zernio API key for sending messages |
ZERNIO_WEBHOOK_SECRET | Recommended | HMAC-SHA256 secret for verifying inbound webhooks |
ZERNIO_API_BASE_URL | No | Override the API base URL (default: https://zernio.com/api) |
ZERNIO_BOT_NAME | No | Bot display name (default: "Zernio Bot") |
Explicit configuration
import { createZernioAdapter } from "@zernio/chat-sdk-adapter";
const adapter = createZernioAdapter({
apiKey: "your-api-key",
webhookSecret: "your-webhook-secret",
baseUrl: "https://zernio.com/api",
botName: "My Bot",
});Setup
- Get a Zernio API key. Sign up at zernio.com and create an API key from the dashboard with read-write permissions.
- Connect social accounts. Use the Zernio dashboard or API to connect the platform accounts you want the bot to handle.
- Configure a webhook pointing at your bot's webhook endpoint:
- URL:
https://your-app.com/api/chat-webhook - Events:
message.receivedandcomment.received - Secret: strong shared secret, passed as
ZERNIO_WEBHOOK_SECRET
- URL:
- Enable the inbox addon on your Zernio account to receive message webhooks.
How it works
Incoming
User sends a DM on Instagram/Telegram/etc.
-> Platform delivers to Zernio
-> Zernio fires `message.received` webhook
-> Adapter verifies signature & parses payload
-> Chat SDK routes through your handlers
Outgoing
Your handler calls thread.post("Hello!")
-> Adapter calls the Zernio REST API
-> Zernio delivers to the correct platform
-> User receives the message on the originating platformThread ID format
Thread IDs follow the format zernio:{accountId}:{conversationId}:
accountId— the Zernio social account ID (which platform account received the message)conversationId— the Zernio conversation ID (the specific DM thread)- For comments:
zernio:{accountId}:comment:{postId}
import { ZernioAdapter } from "@zernio/chat-sdk-adapter";
const adapter = new ZernioAdapter({ apiKey: "..." });
const { accountId, conversationId } = adapter.decodeThreadId(threadId);Platform support matrix
| Feature | FB | IG | Telegram | X | Bluesky | ||
|---|---|---|---|---|---|---|---|
| Send text | Y | Y | Y | Y | Y | Y | Y |
| Buttons | Y | Y | Y | Y | – | – | – |
| Lists | – | – | – | Y | – | – | – |
| Location / Contacts | – | – | – | Y | – | – | – |
| Templates / Flows | – | – | – | Y | – | – | – |
| Typing | Y | – | Y | Y | – | – | – |
| Delete | – | – | Y | – | Y | Self | Self |
| Reactions | – | – | Y | Y | – | – | – |
| Media | Y | Y | Y | Y | Y | – | – |
| Edit | – | – | Y | – | – | – | – |
Rich messages
The adapter maps Chat SDK Card elements to native platform formats instead of falling back to plain text:
import { Actions, Button, Card, CardText, LinkButton } from "chat";
await thread.post(
Card({
title: "Order #1234",
subtitle: "Total: $50.00",
imageUrl: "https://example.com/product.jpg",
children: [
CardText("Your order is ready for pickup."),
Actions([
Button({ id: "confirm", label: "Confirm", style: "primary" }),
LinkButton({ label: "Track Order", url: "https://example.com/track" }),
]),
],
})
);Renders as an interactive card on Facebook, Instagram, Telegram, and WhatsApp. Falls back to plain text on X, Bluesky, and Reddit.
A card Select or RadioSelect maps to a WhatsApp interactive list (it can't coexist with reply buttons, so the list takes precedence):
import { Actions, Card, Select, SelectOption } from "chat";
await thread.post(
Card({
title: "Pick a plan",
children: [
Actions([
Select({
id: "plan",
placeholder: "Choose plan", // becomes the list's open button (max 20 chars)
options: [
SelectOption({ label: "Basic", value: "basic", description: "$10/mo" }),
SelectOption({ label: "Pro", value: "pro" }),
],
}),
]),
],
})
);WhatsApp rich messages
WhatsApp-only message types that don't map to a Chat SDK card are sent through the exported ZernioApiClient, used alongside the adapter. Decode a thread ID to get the accountId and conversationId:
import { ZernioApiClient } from "@zernio/chat-sdk-adapter";
const client = new ZernioApiClient(process.env.ZERNIO_API_KEY!, "https://zernio.com/api");
const { accountId, conversationId } = adapter.decodeThreadId(threadId);
// Reply buttons / list / cta_url / flow / location-request / voice-call button
await client.sendInteractive(conversationId, accountId, {
type: "cta_url",
body: { text: "View your order" },
action: { name: "cta_url", parameters: { display_text: "Open", url: "https://example.com/o/123" } },
});
// Location pin
await client.sendLocation(conversationId, accountId, {
latitude: 41.3874, longitude: 2.1686, name: "HQ", address: "Barcelona",
});
// Contact cards (vCard)
await client.sendContacts(conversationId, accountId, [
{ name: { formatted_name: "Ana Ruiz" }, phones: [{ phone: "+34600000000", type: "WORK" }] },
]);
// Approved template (re-opens the 24h window)
await client.sendTemplate(conversationId, accountId, { name: "order_update", language: "en_US" });
// Quote / reply to a specific message
await client.reply(conversationId, accountId, "wamid.HBg...", "Thanks, on it!");sendInteractive accepts the full WhatsApp interactive union: button, list, cta_url, flow, location_request_message, and voice_call. Pass { replyTo } as the fourth argument to quote a message.
Inbound interactive replies
When a user taps a reply button, selects a list row, or submits a WhatsApp Flow, it arrives as a normal onNewMessage whose interactive context is on message.raw.metadata:
bot.onNewMessage(/.*/, async (thread, message) => {
const meta = (message.raw as { metadata?: Record<string, unknown> }).metadata;
if (meta?.interactiveType === "button_reply" || meta?.interactiveType === "list_reply") {
await thread.post(`You picked: ${meta.interactiveId}`); // the id you set when sending
}
if (meta?.interactiveType === "nfm_reply") {
const form = meta.flowResponseData; // parsed Flow response
}
// meta.referral -> Click-to-WhatsApp ad attribution (when the chat started from an ad)
// meta.quotedMessageId -> the message this one replies to
});Opening conversations
Start a chat with someone who hasn't messaged you yet. openDM(userId) is the standard Chat SDK method — because one Zernio account is one channel, namespace the recipient as "{accountId}:{recipient}" (a phone/E.164 for WhatsApp). It's resolution-only (no network call); the first post() opens the thread:
const thread = await bot.openDM("507f1f77bcf86cd799439011:16505551234");
await thread.post("Hi!"); // WhatsApp: the first message must be an approved templateFor WhatsApp you must open with an approved template (the 24-hour-window rule). openConversation sends it and returns the thread ID in one step:
const threadId = await adapter.openConversation({
accountId: "507f1f77bcf86cd799439011",
to: "16505551234",
template: { name: "welcome", language: "en_US", params: ["Ana"] },
});
// Non-WhatsApp platforms can open with a plain message instead:
// await adapter.openConversation({ accountId, to, message: "Hi!" });AI streaming
Stream AI responses using the post+edit pattern — thread.post() accepts an AsyncIterable<string>, so you can pass the textStream from streamText directly:
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
bot.onNewMessage(/.*/, async (thread, message) => {
const result = streamText({
model: openai("gpt-4o"),
prompt: message.text,
});
// Telegram: posts initial message, edits as tokens arrive.
// Other platforms: collects the full response, posts once.
await thread.post(result.textStream);
});Platform-specific data
Reach platform-specific fields through message.raw:
bot.onNewMessage(/.*/, async (thread, message) => {
const raw = message.raw as {
platform: string;
sender: {
instagramProfile?: { followerCount: number; isVerified: boolean };
phoneNumber?: string;
};
};
console.log(raw.platform); // "instagram", "facebook", "telegram", ...
if (raw.sender.instagramProfile) {
console.log(`Followers: ${raw.sender.instagramProfile.followerCount}`);
console.log(`Verified: ${raw.sender.instagramProfile.isVerified}`);
}
if (raw.sender.phoneNumber) {
console.log(`Phone: ${raw.sender.phoneNumber}`);
}
});API client
The adapter ships a standalone REST client for direct Zernio API calls:
import { ZernioApiClient } from "@zernio/chat-sdk-adapter";
const client = new ZernioApiClient("your-api-key", "https://zernio.com/api");
const { data, pagination } = await client.listConversations({
platform: "instagram",
status: "active",
limit: 20,
});
const messages = await client.fetchMessages(conversationId, accountId);
await client.sendTyping(conversationId, accountId);
await client.addReaction(conversationId, messageId, accountId, "👍");
const { url } = await client.uploadMedia(fileBuffer, "image/jpeg");
// Cold-start a conversation from a recipient (WhatsApp needs a template)
const convo = await client.createConversation({
accountId,
participantId: "16505551234",
templateName: "welcome",
templateLanguage: "en_US",
});It also exposes the WhatsApp rich sends shown above (sendInteractive, sendLocation, sendContacts, sendTemplate, reply).
Webhook verification
The adapter automatically verifies webhook signatures when webhookSecret is configured. You can also call the verifier directly:
import { verifyWebhookSignature } from "@zernio/chat-sdk-adapter";
const isValid = verifyWebhookSignature(rawBody, signature, secret);Error handling
The adapter maps Zernio API errors to standard Chat SDK error classes:
| HTTP status | Error class | Description |
|---|---|---|
| 401 | AuthenticationError | Invalid or expired API key |
| 403 | PermissionError | Read-only key, missing addon, etc. |
| 404 | ResourceNotFoundError | Conversation or message not found |
| 429 | AdapterRateLimitError | Rate limit hit (includes retryAfter) |
| 5xx | NetworkError | Server error |
Feature support
Messaging
| Feature | Supported |
|---|---|
| Post message | |
| Edit message | Telegram |
| Delete message | Telegram, X |
| File uploads | |
| Streaming | post+edit (Telegram) |
| Scheduled messages |
Rich content
| Feature | Supported |
|---|---|
| Card format | FB, IG, Telegram, WhatsApp |
| Buttons | FB, IG, Telegram, WhatsApp |
| Link buttons | FB, IG, Telegram, WhatsApp |
| Select menus | WhatsApp (interactive list) |
| Tables | |
| Fields | |
| Images in cards | FB, IG, Telegram, WhatsApp |
| Modals |
Conversations
| Feature | Supported |
|---|---|
| Slash commands | |
| Mentions | |
| Add reactions | Telegram, WhatsApp |
| Remove reactions | Telegram, WhatsApp |
| Typing indicator | FB, Telegram |
| DMs | |
| Ephemeral messages | |
| User lookup | |
| Parent subject | |
| Native client | |
| Custom API endpoint | baseUrl |
Message history
| Feature | Supported |
|---|---|
| Fetch messages | |
| Fetch single message | |
| Fetch thread info | |
| Fetch channel messages | |
| List threads | |
| Fetch channel info | |
| Post channel message |