---
source: https://threa.io/developers/incoming-webhooks
notes: Markdown mirror generated from the page above. YOUR_WORKSPACE_ID is the ws_… id in the app URL after /w/; YOUR_API_KEY is a key from Settings > API keys.
---

# Incoming webhooks.

A secret URL that posts into one channel or scratchpad as one of your bots. No API key: the URL is the credential. Tools that already send to Slack webhooks can send here unchanged.

## Create a webhook

Open **Workspace Settings → Bots**, pick a bot, and use **New webhook** in its Webhooks section. A webhook belongs to that bot and targets one stream. Creating it adds the bot to that stream, and every message the hook delivers is posted by the bot.

The target must be a channel or a scratchpad. Threads are refused, and so are end-to-end encrypted streams. A webhook holds no key, so it cannot seal what it posts.

Both URLs are shown once, at creation. Copy them then. You can rename a hook or move it to another stream afterwards without the URL changing, and revoke it from the same panel. A revoked hook rejects deliveries with `404 NOT_FOUND` on the native URL and `404 no_service` on `/slack`, and cannot be restored.

## The native URL

`POST` a JSON object with a `content` string holding [Threa markdown](https://threa.io/developers/markdown.md). A delivered message answers `201` with `{"ok": true}`.

*Post a message:*

```bash
curl -X POST \
  https://app.threa.io/api/v1/workspaces/WORKSPACE_ID/hooks/hook_ID/SECRET \
  -H "Content-Type: application/json" \
  -d '{"content": "Deploy **v2.4.1** finished. [Run log](https://ci.example.com/42)"}'
```

Errors come back in the API's usual JSON error shape (see [Operations](https://threa.io/developers/operations.md)). An empty or missing `content` is a `400`. An unknown hook, a wrong secret, and a revoked hook are all `404 NOT_FOUND`, so a caller holding a bad URL learns nothing about which part was wrong.

## The Slack URL

The same URL with `/slack` on the end accepts a Slack incoming webhook payload and translates it to markdown. Paste it wherever a tool asks for a Slack webhook URL.

*Post a Slack payload:*

```bash
curl -X POST \
  https://app.threa.io/api/v1/workspaces/WORKSPACE_ID/hooks/hook_ID/SECRET/slack \
  -H "Content-Type: application/json" \
  -d '{"text": "Deploy *v2.4.1* finished. <https://ci.example.com/42|Run log>"}'
```

It reads the payload three ways, because senders differ: a JSON body, a form-encoded body with the JSON in a `payload` field, and a JSON body sent with a wrong or missing `Content-Type`.

Responses follow Slack's convention rather than the API's, because Slack senders show the response body verbatim in their delivery logs. Success is `200` with a `text/plain` body of `ok`. Failures are a plain-text reason:

| Status | Body | Meaning |
| --- | --- | --- |
| 400 | `invalid_payload` | Body is not a JSON object, or is unparseable or too large. |
| 400 | `no_text` | The payload parsed but held nothing renderable. |
| 404 | `no_service` | Unknown hook, wrong secret, revoked hook, or the bot can no longer post to the stream. |
| 429 | `rate_limited` | A limit below was hit. The response carries `Retry-After`. |
| 500 | `server_error` | Delivery failed on our side. |

`no_service` covers every workspace-side refusal, so a caller who only holds a URL does not learn why the workspace refused.

## What Slack fields become

Threa renders the fields below and skips everything else.

-   `text` is read as mrkdwn: `*bold*`, `_italic_`, `~strike~`, code spans and fences, and the angle-bracket control sequences, so `<https://example.com|label>` becomes a markdown link and `<!here>` becomes `@here`. Only `http`, `https` and `mailto` targets become links, here and in every field below. Any other target renders as its label.
-   `blocks`: `header`, `section` (including `fields`), `context`, `divider`, `image`, `actions` (the elements carrying a URL, rendered as links), and `rich_text` with its sections, lists, quotes and preformatted blocks. Any other block type is skipped.
-   `attachments`: `pretext`, author name and link, `title` with `title_link`, `text`, `fields`, nested `blocks`, `image_url` and `footer`. An attachment that renders to nothing falls back to its `fallback` string.

Rendered top-level `blocks` suppress top-level `text`, which Slack treats as the notification fallback for them. Attachments are appended after whichever of the two won, separated by a blank line.

Fields that address a Slack workspace are ignored: `username`, `icon_emoji`, `icon_url` and `channel` change nothing. The message posts as the hook's bot, in the hook's stream.

## Setting up senders

Anything that posts a Slack-shaped payload works. Three common ones:

### PostHog

PostHog's own Slack destination connects through a Slack app and has no URL field, so use its HTTP webhook destination instead. Set the URL to the `/slack` URL, the method to `POST`, and the JSON body to a Slack payload such as `{"text": "..."}` built from the event fields you want in the message.

### Grafana

Create a contact point of type Slack and paste the `/slack` URL as its webhook URL, then point an alert notification policy at it. Grafana's Slack payload uses `attachments` with title, text and fields.

### GitHub

GitHub's own repository webhooks send GitHub's payload shape, not Slack's, so pointing one at the `/slack` URL does not work. There is no native GitHub endpoint yet. Until there is, post from a workflow step, with the `/slack` URL if you want to reuse a Slack-shaped payload or the native URL if you would rather write the markdown yourself. Keep the URL in a repository secret.

*GitHub Actions step:*

```text
- name: Notify Threa
  run: |
    curl -X POST "$THREA_HOOK_URL" \
      -H "Content-Type: application/json" \
      -d "$(jq -n --arg content "${GITHUB_WORKFLOW} finished on ${GITHUB_REF_NAME}" '{content: $content}')"
  env:
    THREA_HOOK_URL: ${{ secrets.THREA_HOOK_URL }}
```

## Limits

-   300 requests per minute per IP, counted before the secret is checked.
-   60 requests per minute per hook, counted after it is checked, so a caller who guesses a hook id cannot exhaust that hook's budget.
-   256 KB request body.
-   50,000 characters per message, measured after a Slack payload is translated. A longer one is rejected with `400`.
-   25 active webhooks per bot. Revoked ones do not count.

## Security

The secret in the path is the credential. Anyone holding the URL can post to that stream as that bot, so treat it like a password: keep it in a secret store, not in a repository or a shared document. There is no signing secret and no request signature to verify.

A message from a hook can mention people and use `@here` and `@channel`, and those notify like any other message. It cannot attach files or link an existing attachment.

There is no way to rotate a secret in place. To rotate, create a second hook on the same bot and stream, move the sender across, then revoke the first.
