Threa Developers

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

    Post a message
    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.

    Post a Slack payload
    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:

    StatusBodyMeaning
    400invalid_payloadBody is not a JSON object, or is unparseable or too large.
    400no_textThe payload parsed but held nothing renderable.
    404no_serviceUnknown hook, wrong secret, revoked hook, or the bot can no longer post to the stream.
    429rate_limitedA limit below was hit. The response carries Retry-After.
    500server_errorDelivery 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
    - 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.