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.

Vendor-official adapter maintained by Zernio, not Vercel or Chat SDK contributors. For feature requests, bug reports, and support, file an issue on the adapter's repo.

Install

pnpm add @zernio/chat-sdk-adapter chat @chat-adapter/state-memory

For 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

lib/bot.ts
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}!`);
});
app/api/chat-webhook/route.ts
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

VariableRequiredDescription
ZERNIO_API_KEYYesZernio API key for sending messages
ZERNIO_WEBHOOK_SECRETRecommendedHMAC-SHA256 secret for verifying inbound webhooks
ZERNIO_API_BASE_URLNoOverride the API base URL (default: https://zernio.com/api)
ZERNIO_BOT_NAMENoBot 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

  1. Get a Zernio API key. Sign up at zernio.com and create an API key from the dashboard with read-write permissions.
  2. Connect social accounts. Use the Zernio dashboard or API to connect the platform accounts you want the bot to handle.
  3. Configure a webhook pointing at your bot's webhook endpoint:
    • URL: https://your-app.com/api/chat-webhook
    • Events: message.received and comment.received
    • Secret: strong shared secret, passed as ZERNIO_WEBHOOK_SECRET
  4. 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 platform

Thread 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

FeatureFBIGTelegramWhatsAppXBlueskyReddit
Send textYYYYYYY
ButtonsYYYY
ListsY
Location / ContactsY
Templates / FlowsY
TypingYYY
DeleteYYSelfSelf
ReactionsYY
MediaYYYYY
EditY

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 template

For 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 statusError classDescription
401AuthenticationErrorInvalid or expired API key
403PermissionErrorRead-only key, missing addon, etc.
404ResourceNotFoundErrorConversation or message not found
429AdapterRateLimitErrorRate limit hit (includes retryAfter)
5xxNetworkErrorServer error

Feature support

Messaging

FeatureSupported
Post message
Edit messageTelegram
Delete messageTelegram, X
File uploads
Streamingpost+edit (Telegram)
Scheduled messages

Rich content

FeatureSupported
Card formatFB, IG, Telegram, WhatsApp
ButtonsFB, IG, Telegram, WhatsApp
Link buttonsFB, IG, Telegram, WhatsApp
Select menusWhatsApp (interactive list)
Tables
Fields
Images in cardsFB, IG, Telegram, WhatsApp
Modals

Conversations

FeatureSupported
Slash commands
Mentions
Add reactionsTelegram, WhatsApp
Remove reactionsTelegram, WhatsApp
Typing indicatorFB, Telegram
DMs
Ephemeral messages
User lookup
Parent subject
Native client
Custom API endpointbaseUrl

Message history

FeatureSupported
Fetch messages
Fetch single message
Fetch thread info
Fetch channel messages
List threads
Fetch channel info
Post channel message