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
npm install chat @adeli/chat-sdk-adapterThe 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
- 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. - 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>. - Deploy the webhook route from the quick start, for
example at
https://bot.example.com/api/webhooks/adeli. It must behttps. - Register the webhook for
message.created,message.postbackandcomment.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"]
}'- 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. - 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:
// 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.");
});// 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
| Variable | Required | Description |
|---|---|---|
ADELI_API_KEY | Yes | Adeli API key (rk_live_…), from Settings → API keys in the Adeli dashboard |
ADELI_WEBHOOK_SECRET | Yes | Signing secret of the Adeli webhook that delivers to your bot (whsec_…) |
ADELI_PROFILE_ID | No | Adeli profile id. Only needed when the key can reach more than one profile |
ADELI_BASE_URL | No | Adeli API origin. Defaults to https://app.tryadeli.com |
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | () => string | Promise<string> | ADELI_API_KEY | Adeli API key, or a function returning it. Resolved per request |
webhookSecret | string | () => string | Promise<string> | ADELI_WEBHOOK_SECRET | Webhook signing secret, or a function returning it. Resolved per webhook |
profileId | string | ADELI_PROFILE_ID | Profile id sent with API calls. Also scopes Chat SDK locks and dedupe |
baseURL | string | ADELI_BASE_URL, then https://app.tryadeli.com | Adeli API origin |
userName | string | "adeli-bot" | Bot display name |
webhookToleranceSeconds | number | 300 | Maximum age of a webhook's signed timestamp |
fetch | typeof fetch | globalThis.fetch | fetch implementation for API calls |
logger | Logger | The Chat instance's logger | Custom 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:
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 WhatsAppwa_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:
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:
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
| Feature | Instagram DM | Facebook DM | WhatsApp DM | Instagram comments | TikTok comments |
|---|---|---|---|---|---|
Receive messages (onNewMention, onSubscribedMessage) | Yes | Yes | Yes | Yes | Yes |
| Post messages and replies | Yes | Yes | Yes | Yes | Yes |
Buttons and link buttons (onAction) | Yes, up to 3 | Text fallback | Text fallback | Text fallback | Text fallback |
| Streaming | Buffered, posted once | Buffered | Buffered | Buffered | Buffered |
Fetch history (fetchMessages) | Yes | Yes | Yes | Yes | Yes |
| Delete a sent message | No | No | No | Yes, own comments | Yes, own comments |
openDM | Yes | Yes | Yes | n/a | n/a |
| Inbound attachments | Yes | Yes | Yes | n/a | n/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
AdapterErrorwith codewindow_closed. To open a WhatsApp conversation, send an approved template with@adeli/sdkmessages.sendMessage({ body: { platform: "whatsapp", template } })(Send message); Chat SDK messages cannot carry templates. - No edits, reactions or typing indicators.
editMessage,addReaction,removeReactionandstartTypingthrowNotImplementedError.deleteMessageworks only on the account's own comments. - Instagram allows three buttons. A card with more throws
ValidationError. A button'sidandvaluemust fit Instagram's 1,000-character postback payload. - No outbound files. Posting files or attachments throws
ValidationError. - History reads backward.
fetchMessageswithdirection: "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 getsAdapterRateLimitError(withretryAfterset to the reset time). Provider rate limits (429) map to the same error. - Errors.
401maps toAuthenticationError,403 permission_requiredtoPermissionError(reconnect the account with the missing permission),404toResourceNotFoundError,400/422toValidationError, and5xxor network failures toNetworkError, all from@chat-adapter/shared.