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. A delivered
message answers 201 with {"ok": true}.
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). 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.
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.
-
textis 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. Onlyhttp,httpsandmailtotargets become links, here and in every field below. Any other target renders as its label. -
blocks:header,section(includingfields),context,divider,image,actions(the elements carrying a URL, rendered as links), andrich_textwith its sections, lists, quotes and preformatted blocks. Any other block type is skipped. -
attachments:pretext, author name and link,titlewithtitle_link,text,fields, nestedblocks,image_urlandfooter. An attachment that renders to nothing falls back to itsfallbackstring.
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.
- 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.