> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mantle.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a bot

> Post into channels as your own bot, and hear their messages, with a bot token and signed events.

A bot is an app your organization makes. Installed in a workspace, it gets a token. Added to a channel, it can post
there and, if you let it, hear the channel's new messages at a URL you choose. That makes it two-way: a bot can answer a
question, react to a deploy message, or keep a thread up to date.

A bot's posts show the app's name with an **APP** badge. They never appear under the name of the person who installed
it.

<Tip>
  A bot that only posts can be simpler as an [incoming webhook](/integrations/incoming-webhooks): one secret URL for one
  channel, no token. Make a bot when it should post to several channels, reply in threads, or hear what people write.
</Tip>

## Make and install a bot

<Steps>
  <Step title="Make an app">
    In your organization's settings, open **Developer**, then the **Apps** tab, and select **New app**. Give it the name
    its posts should show, such as **Deploy bot**. Organization owners and admins make apps.
  </Step>

  <Step title="Install it in a workspace">
    Select the app, and under the workspace choose what it can do: **Post messages** (`chat:write`), and **Hear
    messages** (`messages:read`) if it should get each new message in its channels. Select **Install**. Workspace owners
    and admins install apps.
  </Step>

  <Step title="Copy its token">
    Mantle shows the bot token once. Store it right away in your bot's secret manager, for example as
    `MANTLE_BOT_TOKEN`.
  </Step>

  <Step title="Add it to channels">
    In a channel, open the **···** menu and choose **Integrations**, then **Add source**. Under **Bots**, select the
    app. A bot can only post to, and hear, the channels it has been added to.
  </Step>
</Steps>

To add a bot to a channel, you need to be able to manage the channel: you created it, or you're a workspace admin or
owner.

<Note>
  If you lose the token, open the app on the Developer page and select **Rotate token…**. The old token stops working at
  once.
</Note>

## Check the token

`GET /v1/me` answers with the app, its workspace, and what it may do. Every request sends the token as a bearer token:

```bash theme={null}
curl https://api.mantle.chat/v1/me \
  -H "Authorization: Bearer $MANTLE_BOT_TOKEN"
```

```json theme={null}
{
  "app": { "id": "4d2c…", "name": "Deploy bot" },
  "installationId": "9a1e…",
  "workspaceId": "b7f3…",
  "userId": "app-bot:9a1e…",
  "scopes": ["chat:write", "messages:read"]
}
```

`userId` is the id the bot's own posts carry as their author. `GET /v1/channels` lists the channels it has been added
to, as `{ "data": [{ "id", "name", "archived" }] }`.

## Post a message

Send a `POST` to `/v1/channels/{channelId}/messages` with a JSON body that has a `text` field:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.mantle.chat/v1/channels/$CHANNEL_ID/messages" \
    -H "Authorization: Bearer $MANTLE_BOT_TOKEN" \
    -H 'content-type: application/json' \
    -H 'Idempotency-Key: deploy-1042-finished' \
    -d '{"text":"Deploy 1042 finished"}'
  ```

  ```javascript JavaScript theme={null}
  import { MantleBotClient } from '@mantlechat/sdk';

  const bot = new MantleBotClient({ token: process.env.MANTLE_BOT_TOKEN });

  await bot.messages.post(channelId, {
    text: 'Deploy 1042 finished',
    idempotencyKey: 'deploy-1042-finished',
  });
  ```
</CodeGroup>

| Field | Required | What it does |
| - | - | - |
| `text` | Yes | The message, in Mantle's markdown. Up to 40,000 characters. |
| `threadKey` | No | Posts that share a key go into one thread. The first post with a new key starts it. 1–200 characters. |
| `replyToMessageId` | No | Replies in that message's thread. Not with `threadKey`. |

An `Idempotency-Key` header makes a retry safe: a repeat of the key answers with the first post and posts nothing new.

A post that went through answers `200`:

```json theme={null}
{ "messageId": "6f0c…", "duplicate": false, "threadRootMessageId": "2b8d…" }
```

`duplicate` is `true` when the request repeated an earlier `Idempotency-Key`. `threadRootMessageId` is there when the
post went into a thread.

The markdown follows the [incoming webhooks rules](/integrations/incoming-webhooks#format-a-message): mentions don't
notify anyone, images show as links, and HTML shows as text.

## Hear messages

A bot installed with **Hear messages** gets a `message.created` event for each new message in its channels, signed so it
can tell the event came from Mantle.

<Steps>
  <Step title="Give it an event URL">
    Open the app on the Developer page and, under **Events**, enter a public `https://` URL on port 443. Select **Save
    event URL**.
  </Step>

  <Step title="Copy its signing secret">
    Mantle shows the signing secret once. Store it next to the token, for example as `MANTLE_WEBHOOK_SECRET`.
  </Step>

  <Step title="Send a test event">
    Select **Send test event**. Mantle sends an `app.test` event and shows how your URL answered.
  </Step>
</Steps>

Each event is a `POST` with a JSON body:

```json theme={null}
{
  "schemaVersion": "mantle-webhook-event.v1",
  "id": "c3f1…",
  "type": "message.created",
  "version": 1,
  "occurredAt": "2026-10-19T12:00:00Z",
  "workspaceId": "b7f3…",
  "data": {
    "message": {
      "id": "6f0c…",
      "channelId": "0d9e…",
      "threadRootMessageId": null,
      "text": "Can someone ship the hotfix?",
      "createdAt": "2026-10-19T12:00:00.123Z",
      "author": { "kind": "person", "id": "user_01J…", "name": "Ada Lovelace" }
    }
  }
}
```

* `threadRootMessageId` is the message whose thread this one is in, or `null` for a message in the channel itself.
* `author.kind` is `person` or `app`. `author` is `null` once the author's account is gone.
* A bot never gets its own posts. It does get other apps' posts, so a bot that answers bots should make sure two of them
  can't answer each other forever.
* Agent replies, Linear, Stripe and Notion posts, and direct messages aren't sent. Incoming webhooks' posts are, as an
  app's.
* The text is read when the event is sent: a message edited first is sent as it is then, and one deleted first isn't
  sent.

Answer with any `2xx` within 10 seconds. Do slow work after you answer.

## Check the signature

Every event has these headers:

| Header | What it holds |
| - | - |
| `mantle-webhook-id` | The event's id, the same as the body's `id` |
| `mantle-webhook-timestamp` | When it was signed, in Unix seconds |
| `mantle-webhook-signature` | `v1=` and an HMAC-SHA256, in hex, of `{timestamp}.{id}.{body}` |
| `mantle-webhook-event` | The event's type, such as `message.created` |

Check the signature against the exact body you received, before parsing it, and refuse events more than 5 minutes old.
The SDK does all of this:

<CodeGroup>
  ```javascript JavaScript theme={null}
  import { verifyAndParseMantleWebhookEvent } from '@mantlechat/sdk';

  const event = await verifyAndParseMantleWebhookEvent({
    eventId: request.headers.get('mantle-webhook-id'),
    timestamp: Number(request.headers.get('mantle-webhook-timestamp')),
    signature: request.headers.get('mantle-webhook-signature'),
    rawBody: await request.text(),
    secret: process.env.MANTLE_WEBHOOK_SECRET,
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time

  def verify(headers, raw_body: bytes) -> bool:
      timestamp = headers["mantle-webhook-timestamp"]
      event_id = headers["mantle-webhook-id"]
      if abs(time.time() - int(timestamp)) > 300:
          return False
      signed = f"{timestamp}.{event_id}.".encode() + raw_body
      expected = hmac.new(os.environ["MANTLE_WEBHOOK_SECRET"].encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(f"v1={expected}", headers["mantle-webhook-signature"])
  ```
</CodeGroup>

## A small echo bot

This bot answers "pong" in the thread of any message that says "ping". It runs on [Bun](https://bun.sh): save it as
`bot.js`, set the two environment variables, and run `bun bot.js`. Then use its public `https://` address as the event
URL.

```javascript theme={null}
import { MantleBotClient, verifyAndParseMantleWebhookEvent } from '@mantlechat/sdk';

const bot = new MantleBotClient({ token: process.env.MANTLE_BOT_TOKEN });

export default {
  async fetch(request) {
    let event;
    try {
      event = await verifyAndParseMantleWebhookEvent({
        eventId: request.headers.get('mantle-webhook-id') ?? '',
        timestamp: Number(request.headers.get('mantle-webhook-timestamp')),
        signature: request.headers.get('mantle-webhook-signature') ?? '',
        rawBody: await request.text(),
        secret: process.env.MANTLE_WEBHOOK_SECRET,
      });
    } catch {
      return new Response('Not from Mantle', { status: 401 });
    }

    if (event.type === 'message.created') {
      const { message } = event.data;
      if (message.author?.kind === 'person' && /\bping\b/i.test(message.text)) {
        await bot.messages.post(message.channelId, {
          text: 'pong',
          replyToMessageId: message.id,
          // The event's id, so a retried event answers once.
          idempotencyKey: event.id,
        });
      }
    }
    return new Response(null, { status: 204 });
  },
};
```

## Retries and pausing

An event that fails is sent again after 10 seconds, 1 minute, 5 minutes, and 30 minutes. After the fifth failure, Mantle
gives up on it.

* **Retried:** network errors, timeouts, and the answers `408`, `409`, `425`, `429`, and any `5xx`.
* **Not retried:** any other `4xx`, and a URL that isn't a public `https://` address.

If a bot's events keep failing for an hour, they pause. The app's page on the Developer page says so. Fix the receiver,
send a test event, then select **Resume events**. Messages posted while events were paused aren't sent.

## Errors

A refused request answers with an error body:

```json theme={null}
{ "error": { "code": "not_in_channel", "message": "…", "requestId": "…" } }
```

| Status | `code` | What happened | What to do |
| - | - | - | - |
| `400` | `invalid_request` | The body isn't valid, or has both `threadKey` and `replyToMessageId` | Fix the body |
| `400` | `empty_text` | `text` is empty or only spaces | Send some text |
| `400` | `text_too_long` | `text` is over 40,000 characters | Shorten it or split it |
| `400` | `invalid_thread_key` | `threadKey` is empty or over 200 characters | Use a shorter key |
| `401` | `invalid_token` | The token is wrong, was rotated, or the app was uninstalled or deleted | Use the current token |
| `403` | `app_paused` | The app is [paused](#manage-a-bot) in its workspace | Ask an admin to install it again |
| `403` | `insufficient_scope` | It wasn't installed with **Post messages** | Install it again with that permission |
| `403` | `not_in_channel` | It hasn't been added to the channel | Add it in the channel's **Integrations** |
| `403` | `workspace_not_enabled` | The Mantle API is turned off for the workspace | [Contact us](https://mantle.chat/contact) |
| `404` | `channel_not_found` | No such channel | Check the channel's id |
| `404` | `message_not_found` | `replyToMessageId` isn't a message in the channel | Check the message's id |
| `409` | `channel_archived` | The channel is archived | Unarchive it, or post somewhere else |
| `429` | `rate_limited` | Too many requests in the last minute | Wait for the seconds in `Retry-After` |
| `503` | `service_unavailable` | Mantle couldn't answer just then | Retry in a moment |
| `500` | `post_failed` | Mantle couldn't save the post | Retry with the same `Idempotency-Key` |

## Limits

| Limit | Value |
| - | - |
| Requests per bot | 60 a minute |
| Requests per workspace, in total | 600 a minute |
| Message text | 40,000 characters |
| App name | 80 characters |
| Event answer | 10 seconds |
| Events kept for retries | 3 days |

## Manage a bot

Open the app on the Developer page.

* **Rotate token…** issues a new token; the old one stops working at once.
* **Change URL** points its events somewhere else, with the same signing secret. **Rotate signing secret…** issues a new
  one, and **Stop events…** stops sending it messages.
* **Uninstall…** removes it from the workspace: its token stops working and it leaves its channels. Its posts stay.
* **Delete app** uninstalls it everywhere.

If the person who installed a bot leaves the workspace, the bot pauses: its requests answer `403 app_paused`. A
workspace admin can select **Install again** to give it a new token.

To take a bot out of one channel, open the channel's **Integrations**, select the bot, and choose **Remove from
channel**. The person who added it, or a workspace admin, can.

## Keep the token secret

<Warning>
  Anyone with the token can post as the bot in its channels. Anyone with the signing secret could send your bot events
  that look like Mantle's. Store both as secrets, never in a repository or a shared document. If either leaks, rotate
  it.
</Warning>

The token can't read a channel's history or anything else in your workspace; a bot hears only the events Mantle sends
it.

<CardGroup cols={2}>
  <Card title="Incoming webhooks" icon="link" href="/integrations/incoming-webhooks">
    Post into one channel with a secret URL, no token.
  </Card>

  <Card title="Channels" icon="hashtag" href="/features/channels">
    Create channels and decide who can manage them.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.