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

A tool that already posts to Slack can post here too, and so can GitHub: see
[Use it in a Slack webhook field](#use-it-in-a-slack-webhook-field) and [Use it with GitHub](#use-it-with-github).

## 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. For a
    Slack webhook field or GitHub, use **Copy Slack URL** or **Copy GitHub URL** instead.
  </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.

## Use it in a Slack webhook field

Many monitoring, uptime, and CI tools can post to a Slack incoming webhook. Give them the webhook's URL with `/slack` on
the end, which **Copy Slack URL** copies, and they post here in Slack's format:

```bash theme={null}
curl -X POST "$MANTLE_WEBHOOK_URL/slack" -H 'content-type: application/json' \
  -d '{"text":"*Deploy* finished: <https://ci.example.com/runs/42|run 42>"}'
```

* **Formatting:** Slack's `*bold*`, `_italic_`, `~strike~`, code, links, and quotes become Mantle's.
* **Blocks, rich text, and attachments** become one post: headers, sections and their fields, context, dividers, lists,
  and quotes, with images and link buttons as links. When a message has blocks, they're the post, and its `text` is only
  Slack's notification text. Menus, inputs, and buttons without a link are left out.
* **Mentions don't notify anyone.** `@here`, `@channel`, people, and channels show as text.
* **The webhook keeps its own name and icon.** `username`, `icon_url`, `icon_emoji`, and `channel` are ignored.
* **Emoji shortcodes** such as `:white_check_mark:` show as text.
* **Slack's form body**, with the JSON in a `payload` field, works too.

It answers the way Slack does: `200 ok` as plain text, or Slack's error code as plain text.

| Status | Answer | What happened |
| - | - | - |
| `400` | `invalid_payload` | The body isn't Slack's format |
| `400` | `no_text` | Nothing in it would show |
| `403` | `action_prohibited` | The webhook is [paused](#when-a-webhook-pauses) |
| `404` | `no_service` | The URL is wrong, was rotated, or its webhook was deleted |
| `410` | `channel_is_archived` | The webhook's channel is archived |

Anything else answers with the code from [Errors](#errors), such as `429 rate_limited`.

## Use it with GitHub

GitHub can send a repository's or an organization's events straight to the webhook.

<Steps>
  <Step title="Open the webhook settings">
    In the repository or organization, go to **Settings → Webhooks** and select **Add webhook**.
  </Step>

  <Step title="Paste the URL">
    In **Payload URL**, paste the webhook's URL with `/github` on the end. **Copy GitHub URL** copies it.
  </Step>

  <Step title="Choose the content type">Choose `application/json`. The form type works too.</Step>
  <Step title="Leave the secret empty">The URL is the secret. Mantle can't check one set here.</Step>

  <Step title="Choose the events">
    Select the events below, or send everything: events Mantle doesn't post are accepted and dropped.
  </Step>
</Steps>

When you add it, GitHub sends a test event, and the channel gets a post saying it's connected.

| Event | Posts when |
| - | - |
| Pushes | Commits are pushed to a branch: up to five commits, linked, and the comparison |
| Pull requests | One is opened, marked ready for review, merged, closed, or reopened |
| Pull request reviews | A review is submitted: approved, changes requested, or commented |
| Issues | One is opened, closed, or reopened |
| Issue comments | Someone comments on an issue or a pull request |
| Releases | One is published |
| Workflow runs | A run finishes, unless it was skipped or neutral |

* **Threads:** a pull request's events, reviews, and comments go into one thread, and an issue's into another. The
  thread starts with the first of its events Mantle sees. Pushes, releases, and runs post in the channel.
* **Excerpts:** a comment, a review, or a new issue shows its first lines as text, and its link opens the rest.
* **Redelivering** an event from GitHub's delivery log doesn't post it twice.
* **Tags and deleted branches** post nothing.
* **Bodies** can be up to 1 MB.
* **A wrong or rotated URL** answers `404`, so GitHub's delivery log shows the event as failed.

## What doesn't work yet

* **Other services' own webhooks**, such as Stripe's or Sentry's, send their own formats, and Mantle answers them
  `400 invalid_payload`. If the service can post to a Slack webhook, use the
  [Slack URL](#use-it-in-a-slack-webhook-field). Otherwise, send a `text` message from a CI step or an automation tool.
* **For Linear, Stripe, and Notion**, add them from **Add source** in the same panel instead. Mantle posts their updates
  for you.

## Errors

Every error answers with a JSON body: `{ "ok": false, "error": "…", "message": "…" }`. The Slack URL answers in
[Slack's way](#use-it-in-a-slack-webhook-field) instead.

| 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, or 1 MB for GitHub's events | 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, or 1 MB for GitHub's events |
| 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.