Build a Chat SDK bot on Instagram, Facebook, WhatsApp and TikTok

@adeli/chat-sdk-adapter is Adeli's adapter for Chat SDK. One bot, with one Adeli API key, answers Instagram, Facebook and WhatsApp DMs, and Instagram and TikTok comments. On Instagram DMs, cards can also carry up to three tappable buttons.

Your users connect their accounts in the Adeli dashboard. Your bot never needs its own Meta app, App Review, or TikTok developer access. The adapter calls the same public REST API and receives the same signed webhooks documented here. The source and the package README are at github.com/kingscrosslabs/adeli-chat-sdk-adapter.

Install

bash
npm install chat @adeli/chat-sdk-adapter

The quick start below keeps Chat SDK state in memory, which also needs npm install @chat-adapter/state-memory. In production, use a persistent state adapter such as @chat-adapter/redis.

Set up Adeli

  1. Create an API key. Sign in at app.tryadeli.com, open Settings → API keys, create a key, and set it as ADELI_API_KEY. See Authentication.
  2. Connect accounts. On the Accounts page, connect the Instagram, Facebook Page, WhatsApp Business or TikTok accounts the bot should answer for. Each connected account is one Chat SDK channel, adeli:<accountId>.
  3. Deploy the webhook route from the quick start, for example at https://bot.example.com/api/webhooks/adeli. It must be https.
  4. Register the webhook for message.created, message.postback and comment.created. In the dashboard, open Webhooks, click Add webhook, and enter the URL. Or call Create webhook:
curl https://app.tryadeli.com/api/v1/webhooks \
  -H "Authorization: Bearer $ADELI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "<your profile id>",
    "url": "https://bot.example.com/api/webhooks/adeli",
    "events": ["message.created", "message.postback", "comment.created"]
  }'
  1. Store the signing secret the response returns as ADELI_WEBHOOK_SECRET. It is shown only once. To rotate it, delete the webhook and create it again.
  2. Send a test event from the webhook's row in the dashboard. Test events ("test": true) are verified and acknowledged without reaching your handlers.

Every delivery is signed with Adeli-Signature (HMAC-SHA256 with a timestamp). The adapter rejects unsigned, mis-signed and stale (over 300 seconds) deliveries with 401, answers 200 at once, and runs your handlers in waitUntil. Adeli retries failed deliveries for about nine hours with the same event id; Chat SDK deduplicates them.

Quick start

A Next.js App Router bot that answers every new DM and top-level comment:

ts
// lib/bot.ts
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createAdeliAdapter } from "@adeli/chat-sdk-adapter";

const bot = new Chat({
  userName: "shopbot",
  adapters: {
    adeli: createAdeliAdapter({
      apiKey: process.env.ADELI_API_KEY!,
      webhookSecret: process.env.ADELI_WEBHOOK_SECRET!,
    }),
  },
  state: createMemoryState(),
});
export { bot };

// A new DM, or a new top-level comment on one of the account's posts.
bot.onNewMention(async (thread, message) => {
  await thread.subscribe();
  await thread.post(`Thanks for reaching out! You said: ${message.text}`);
});

// Later messages in a DM, or replies under the same comment.
bot.onSubscribedMessage(async (thread, message) => {
  await thread.post("Got it. A teammate will follow up shortly.");
});
ts
// app/api/webhooks/adeli/route.ts (Next.js App Router)
import { after } from "next/server";
import { bot } from "@/lib/bot";

export async function POST(request: Request) {
  return bot.webhooks.adeli(request, { waitUntil: (task) => after(() => task) });
}

createAdeliAdapter(config?) falls back to environment variables for every field you leave out, so createAdeliAdapter() with ADELI_API_KEY and ADELI_WEBHOOK_SECRET set works too. A missing required value throws ValidationError naming both the field and the variable.

Environment variables

VariableRequiredDescription
ADELI_API_KEYYesAdeli API key (rk_live_…), from Settings → API keys in the Adeli dashboard
ADELI_WEBHOOK_SECRETYesSigning secret of the Adeli webhook that delivers to your bot (whsec_…)
ADELI_PROFILE_IDNoAdeli profile id. Only needed when the key can reach more than one profile
ADELI_BASE_URLNoAdeli API origin. Defaults to https://app.tryadeli.com

Configuration

OptionTypeDefaultDescription
apiKeystring | () => string | Promise<string>ADELI_API_KEYAdeli API key, or a function returning it. Resolved per request
webhookSecretstring | () => string | Promise<string>ADELI_WEBHOOK_SECRETWebhook signing secret, or a function returning it. Resolved per webhook
profileIdstringADELI_PROFILE_IDProfile id sent with API calls. Also scopes Chat SDK locks and dedupe
baseURLstringADELI_BASE_URL, then https://app.tryadeli.comAdeli API origin
userNamestring"adeli-bot"Bot display name
webhookToleranceSecondsnumber300Maximum age of a webhook's signed timestamp
fetchtypeof fetchglobalThis.fetchfetch implementation for API calls
loggerLoggerThe Chat instance's loggerCustom logger

Rotating credentials

apiKey and webhookSecret each accept a function instead of a string. The adapter calls apiKey before every API request and webhookSecret on every webhook, and never caches the result:

ts
createAdeliAdapter({
  apiKey: async () => (await secrets.get("adeli-api-key")).value,
  webhookSecret: () => process.env.ADELI_WEBHOOK_SECRET!,
});

Threads

The adapter's name is adeli. Each connected account is one channel, adeli:<accountId>, and every thread belongs to one of two kinds:

  • A DM thread is one conversation between a connected account and one person on Instagram, Facebook or WhatsApp. It is keyed by the account and the message's conversationKey, <platform>:<counterparty id>: the person's Instagram-scoped id, Facebook page-scoped id, or WhatsApp wa_id.
  • A comment thread is a top-level comment on an Instagram post or TikTok video, and its replies. It is keyed by the account, the post and the top-level comment.

Every inbound DM and top-level comment is a mention and reaches onNewMention. Replies under a comment reach onSubscribedMessage once the thread is subscribed. A post to a comment thread is a reply under its top-level comment. The bot's own sends never come back as messages: Adeli does not emit echoes.

message.id is the provider's id (an Instagram mid, a WhatsApp wamid, a comment id). message.raw is an AdeliRawMessage: kind, accountId, platform, the provider message or comment id, and the sender. fetchMessages reads a DM's history from GET /api/v1/messages by conversation, and a comment thread's from GET /api/v1/comments.

Starting a DM

openDM takes "<accountId>:<scoped id>" for an Instagram user, or "<accountId>:<platform>:<id>" for any DM platform. It makes no network call; the first post sends the message:

ts
const adeli = bot.getAdapter("adeli");
const threadId = await adeli.openDM(`${accountId}:whatsapp:15555550123`);
await bot.thread(threadId).post("Your order has shipped.");

Buttons (Instagram)

On Instagram DMs, a card's <Button> and <LinkButton> elements become native Instagram buttons, up to three per message. A tap arrives as onAction with the button's id and value:

tsx
import { Actions, Button, Card, LinkButton, Text } from "chat";

bot.onNewMention(async (thread) => {
  await thread.post(
    <Card title="Track your order?">
      <Text>We can send you shipping updates here.</Text>
      <Actions>
        <Button id="track" value="order-42">Yes please</Button>
        <LinkButton url="https://shop.example.com/orders/42">View order</LinkButton>
      </Actions>
    </Card>,
  );
});

bot.onAction("track", async (event) => {
  await event.thread?.post(`Tracking ${event.value} for you.`);
});

On Facebook, WhatsApp and comment threads, cards are sent as plain text, with each link button written as label: url.

Features

FeatureInstagram DMFacebook DMWhatsApp DMInstagram commentsTikTok comments
Receive messages (onNewMention, onSubscribedMessage)YesYesYesYesYes
Post messages and repliesYesYesYesYesYes
Buttons and link buttons (onAction)Yes, up to 3Text fallbackText fallbackText fallbackText fallback
StreamingBuffered, posted onceBufferedBufferedBufferedBuffered
Fetch history (fetchMessages)YesYesYesYesYes
Delete a sent messageNoNoNoYes, own commentsYes, own comments
openDMYesYesYesn/an/a
Inbound attachmentsYesYesYesn/an/a

All five surfaces show plain text. Markdown is flattened: emphasis markers are dropped, links become text (url), list items start with - .

Limitations

  • The 24-hour window. Instagram, Facebook and WhatsApp accept free-text messages only within 24 hours of the person's last message. Outside it, a post throws AdapterError with code window_closed. To open a WhatsApp conversation, send an approved template with @adeli/sdk messages.sendMessage({ body: { platform: "whatsapp", template } }) (Send message); Chat SDK messages cannot carry templates.
  • No edits, reactions or typing indicators. editMessage, addReaction, removeReaction and startTyping throw NotImplementedError. deleteMessage works only on the account's own comments.
  • Instagram allows three buttons. A card with more throws ValidationError. A button's id and value must fit Instagram's 1,000-character postback payload.
  • No outbound files. Posting files or attachments throws ValidationError.
  • History reads backward. fetchMessages with direction: "forward" on a DM reads up to 1,000 messages and returns them in one page.
  • Usage limits. Every bot reply counts as a message or comment in GET /api/v1/usage. A workspace without billing that has used its free monthly sends gets AdapterRateLimitError (with retryAfter set to the reset time). Provider rate limits (429) map to the same error.
  • Errors. 401 maps to AuthenticationError, 403 permission_required to PermissionError (reconnect the account with the missing permission), 404 to ResourceNotFoundError, 400/422 to ValidationError, and 5xx or network failures to NetworkError, all from @chat-adapter/shared.