> ## 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.

# Incoming webhooks

> Post into a channel from your CI, scripts, alerts, and automation tools with a secret URL.

An incoming webhook is a secret URL for one channel. Anything that can send an HTTP request can post there: a CI job, a
deploy script, a cron job, an alert, or an automation tool such as Zapier, Make, or n8n.

Posts show the webhook's own name and icon, with an **APP** badge, so the channel can see what posted. They never appear
under the name of the person who created the webhook.

## Create a webhook

<Steps>
  <Step title="Open the channel's integrations">
    In the channel, open the **···** menu at the top and choose **Integrations**. You can also hover the channel in the
    sidebar and choose **Integrations** from its **···** menu.
  </Step>

  <Step title="Add an incoming webhook">Select **Add source**, then **Incoming webhook**.</Step>

  <Step title="Name it">
    Give it a name, such as **Deploys**, and pick or upload an icon. Its posts show this name and icon.
  </Step>

  <Step title="Copy the URL">
    Select **Create webhook**, then **Copy URL**. Store it right away wherever it posts from, such as a CI secret.
  </Step>
</Steps>

<Note>
  Mantle shows the full URL only once. If you lose it, open the webhook and select **Rotate URL** to get a new one.
</Note>

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

## Send a message

Send a `POST` request to the URL with a JSON body that has a `text` field. These examples read the URL from a
`MANTLE_WEBHOOK_URL` environment variable:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "$MANTLE_WEBHOOK_URL" \
    -H 'content-type: application/json' \
    -d '{"text":"Deploy 1042 finished"}'
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.post(
      os.environ["MANTLE_WEBHOOK_URL"],
      json={"text": "Deploy 1042 finished"},
      timeout=10,
  )
  response.raise_for_status()
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(process.env.MANTLE_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text: 'Deploy 1042 finished' }),
  });
  if (!response.ok) throw new Error(`Mantle answered ${response.status}`);
  ```
</CodeGroup>

The message appears in the channel right away.

### The request body

| Field | Required | What it does |
| - | - | - |
| `text` | Yes | The message, in [Mantle's markdown](#format-a-message). Up to 40,000 characters. |
| `thread_key` | No | Posts that share a key go into one thread. See [Group updates in a thread](#group-updates-in-a-thread). |
| `idempotency_key` | No | Posts once, however often the request is retried. See [Retry safely](#retry-safely). 1 to 200 characters. |

The body can be up to 64 KB. Other fields are ignored.

### The answer

A post that went through answers `200`:

```json theme={null}
{ "ok": true, "message_id": "6f0c…", "duplicate": false }
```

`duplicate` is `true` when the request repeated an earlier `idempotency_key`. With a `thread_key`, the answer also has
`thread_root_message_id`: the message the thread hangs from. Any other status is an [error](#errors).

## Format a message

`text` takes markdown: **bold**, *italic*, ~~strikethrough~~, `inline code`, code blocks, links, lists, and quotes. A
single line break starts a new line, and emoji work as you type them.

Some things are left out, so a webhook can't do more than a person's message:

* **Mentions don't notify anyone.** Writing a name keeps it as plain text.
* **Images show as links.** Mantle doesn't load an image from another site, because loading it would tell that site who
  read the post.
* **HTML shows as text.**
* **Only web addresses become links.** `https://` and `http://` links work; other kinds show as plain text.

## Group updates in a thread

Add a `thread_key` to keep related updates together, such as a build number or a ticket ID. The first post with a new
key goes to the channel. Each later post with the same key replies in that post's thread.

```bash theme={null}
curl -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' \
  -d '{"text":"Build 1042 started","thread_key":"build-1042"}'

curl -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' \
  -d '{"text":"Build 1042: tests passed","thread_key":"build-1042"}'
```

* A key is 1 to 200 characters, and `Build-1042` and `build-1042` are different keys.
* Keys belong to the webhook, so two webhooks can use the same key without sharing a thread.
* If the thread's first post is deleted, the next post with that key starts a new thread.
* Thread replies work like a person's: they mark the channel unread and notify people who follow the thread.

## Retry safely

Networks fail, and a request can reach Mantle even when its answer never comes back. Send an `idempotency_key` so a
retry doesn't post twice: a repeat of a key answers with the first post and `"duplicate": true`, and posts nothing new.

```bash theme={null}
curl -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' \
  -d '{"text":"Invoice 2210 paid","idempotency_key":"invoice-2210-paid"}'
```

Use a key that names the event, so the same event always sends the same key. Keys belong to the webhook.

## Recipes

### GitHub Actions

<Steps>
  <Step title="Add the URL as a secret">
    In the repository, go to **Settings → Secrets and variables → Actions** and add a secret named `MANTLE_WEBHOOK_URL`.
  </Step>

  <Step title="Post from a workflow">
    Add steps like these to a job. Both use the run's ID as the thread key, so the result replies under the first post.
  </Step>
</Steps>

```yaml theme={null}
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      MANTLE_WEBHOOK_URL: ${{ secrets.MANTLE_WEBHOOK_URL }}
    steps:
      - name: Announce the deploy
        run: |
          curl -sf -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' \
            -d '{"text":"Deploying **${{ github.ref_name }}**","thread_key":"deploy-${{ github.run_id }}"}'

      # Your deploy steps go here.

      - name: Report the result
        if: always()
        run: |
          curl -sf -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' \
            -d '{"text":"Deploy ${{ job.status }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}","thread_key":"deploy-${{ github.run_id }}"}'
```

Any other CI works the same way: store the URL as a secret variable, then send the request from a step.

### A script or cron job

Build the JSON with `jq` so quotes and line breaks in the message can't break it:

```bash theme={null}
jq -n --arg text "Backup finished: $(du -sh /backups | cut -f1)" '{text: $text}' \
  | curl -sf -X POST "$MANTLE_WEBHOOK_URL" -H 'content-type: application/json' --data-binary @-
```

### Zapier, Make, and n8n

Add an HTTP request step to your automation and set it up like this. The labels differ a little between tools.

| Setting | Value |
| - | - |
| Method | `POST` |
| URL | Your webhook URL |
| Body type | JSON (`application/json`) |
| Body | A `text` field with your message, for example `New lead: …` |

In Zapier, use **Webhooks by Zapier → POST**. In Make, use **HTTP → Make a request**. In n8n, use the **HTTP Request**
node.

## What doesn't work yet

A webhook takes the request body described above, and nothing else. So:

* **A service's own webhook settings don't work.** GitHub, Stripe, Sentry, and similar services send their own event
  formats, which have no `text` field, so Mantle answers `400 invalid_payload`. Use a CI step or an automation tool to
  send a `text` message instead.
* **A "Slack webhook URL" field works only for plain text.** A tool that sends Slack's blocks or attachments, rather
  than a plain `text` field, loses that formatting or is refused.
* **For Linear, Stripe, and Notion**, add them from **Add source** in the same panel instead. Mantle posts their updates
  for you.

Support for Slack's message format and for GitHub's events is planned.

## Errors

Every error answers with a JSON body: `{ "ok": false, "error": "…", "message": "…" }`.

| Status | `error` | What happened | What to do |
| - | - | - | - |
| `400` | `invalid_payload` | The body isn't JSON, `text` is missing, or `idempotency_key` is empty or too long | Send JSON with a `text` field |
| `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 into several posts |
| `400` | `invalid_thread_key` | `thread_key` is empty or over 200 characters | Use a shorter key |
| `403` | `webhook_paused` | The webhook is [paused](#when-a-webhook-pauses) | Rotate its URL to resume it |
| `404` | `webhook_not_found` | The URL is wrong, was rotated, or its webhook was deleted | Copy the current URL, or rotate the URL if you lost it |
| `409` | `channel_archived` | The webhook's channel is archived | Unarchive the channel, or create a webhook in another channel |
| `413` | `payload_too_large` | The body is over 64 KB | Send a shorter body |
| `429` | `rate_limited` | Too many posts in the last minute | Wait for the seconds in the `Retry-After` header, then retry |
| `503` | `rate_limiter_unavailable` | Mantle couldn't check the rate limit | Retry in a moment |
| `500` | `post_failed` | Mantle couldn't save the post | Retry in a moment |

Retry only `429`, `500`, and `503`, and send the same `idempotency_key` when you do. A wrong URL and a deleted webhook
answer the same `404`, so nobody can use the answer to guess which webhooks exist.

## Limits

| Limit | Value |
| - | - |
| Posts per webhook | 30 a minute |
| Posts per workspace, in total | 300 a minute |
| Request body | 64 KB |
| Message text | 40,000 characters |
| Webhooks per workspace | 25 |
| Webhook name | 80 characters |

## Manage a webhook

Open the channel's **Integrations** and select the webhook. Its page shows how many posts it made in the last 7 days and
when it last posted. A webhook can be changed by the person who created it, or by a workspace admin or owner.

* **Rename it or change its icon** with **Edit**. Its earlier posts show the new name and icon too.
* **Rotate its URL** if the URL leaked or got lost. The old URL stops working at once, and Mantle shows the new one
  once. Update it everywhere the webhook posts from.
* **Delete it** when nothing posts through it anymore. Anything still using its URL stops posting, and what it already
  posted stays in the channel.

### When a webhook pauses

If the person who created a webhook leaves the workspace, the webhook pauses. It shows as **Paused**, and posts answer
`403 webhook_paused`. A workspace admin or owner can resume it by rotating its URL. Then update the URL everywhere the
webhook posts from.

## Keep the URL secret

<Warning>
  Anyone with the URL can post in the channel as the webhook. Treat it like a password: store it as a secret in your CI
  or automation tool, never in a repository or a shared document. If it leaks, rotate it.
</Warning>

The URL only lets someone post. It can't read the channel or anything else in your workspace.

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

  <Card title="Messages and threads" icon="message" href="/features/messages-and-threads">
    Follow a thread to hear about its replies.
  </Card>
</CardGroup>


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