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

> Build a Chat SDK bot that answers Instagram, Facebook and WhatsApp DMs and Instagram and TikTok comments with @adeli/chat-sdk-adapter.

Canonical: <https://www.tryadeli.com/docs/chat-sdk>

`@adeli/chat-sdk-adapter` is Adeli's adapter for [Chat SDK](https://chat-sdk.dev/docs).
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](https://www.tryadeli.com/docs/api) and receives the same signed
[webhooks](https://www.tryadeli.com/docs/api/webhooks) documented here. The source and the package
README are at
[github.com/kingscrosslabs/adeli-chat-sdk-adapter](https://github.com/kingscrosslabs/adeli-chat-sdk-adapter/tree/v1.0.0).

## 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](https://app.tryadeli.com),
   open [Settings → API keys](https://app.tryadeli.com/settings/api-keys),
   create a key, and set it as `ADELI_API_KEY`. See [Authentication](https://www.tryadeli.com/docs/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](https://www.tryadeli.com/docs/chat-sdk#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](https://www.tryadeli.com/docs/api/webhooks/create-webhook):

**cURL**

```bash
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"]
  }'
```

**TypeScript SDK**

```ts
import { Adeli } from "@adeli/sdk";

const adeli = new Adeli({ apiKey: "rk_live_replace_on_server" });

export async function registerBotWebhook(profileId: string) {
  const { data } = await adeli.webhooks.createWebhook({
    body: {
      profileId,
      url: "https://bot.example.com/api/webhooks/adeli",
      events: ["message.created", "message.postback", "comment.created"],
    },
  });
  return data.secret; // shown once
}
```

5. **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.
6. **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](https://www.tryadeli.com/docs/api/webhooks#delivery-and-retries)
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

| 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:

```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`](https://www.tryadeli.com/docs/api/messages/list-messages),
  `<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`](https://www.tryadeli.com/docs/api/messages/list-messages) by conversation,
and a comment thread's from [`GET /api/v1/comments`](https://www.tryadeli.com/docs/api/comments/list-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

| 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 `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](https://www.tryadeli.com/docs/api/messages/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`](https://www.tryadeli.com/docs/api/usage/get-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`.
