Webhooks

An incoming webhook lets another system post into a channel by sending JSON to a secret URL. An outgoing webhook does the opposite: it sends messages to your URL, those that start with a trigger word or those people pick it for in the message menu, and posts what you answer.

Where to manage webhooks

Open the community menu, then Community settings, and click Integrations at the end of its tabs. The integrations pages of the current community list Incoming webhooks and Outgoing webhooks. Each webhook belongs to one community.

By default, community admins and system admins can create webhooks. A system admin can give the right to other roles in the permission settings of the System Console. A system admin can also turn each kind off in System Console › Integrations › Integration Management: Enable Incoming Webhooks and Enable Outgoing Webhooks are both on by default.

A channel with messages from a CI bot and from GitHub, shown as cards with fields, pipeline stages and a button
Integration messages drawn as cards: fields, pipeline stages and an action button.

Create an incoming webhook

  1. In Integrations, choose Incoming webhooks, then Add Incoming Webhook.
  2. Fill in a Title and a Description: they only show on the integrations pages.
  3. Pick the Channel the messages go to by default. For a private channel, you must be a member of it.
  4. Tick Lock to this channel if senders must not be able to post elsewhere.
  5. Optionally set a Username and a Profile Picture URL for its messages (see Name and icon).
  6. Choose Save. Copy the URL shown: https://chat.example.com/hooks/<key>.
The URL is the only secret: anyone who has it can post in the channel. Keep it out of public repositories, and delete the webhook if it leaks.

With the API: POST /api/v4/hooks/incoming with {"channel_id", "display_name", "description", "username", "icon_url", "channel_locked"}; the response's id is the key. From the command line: amctl webhook create-incoming --channel <channel id> --user <user> --display-name "CI".

Send a message to an incoming webhook

curl -X POST https://chat.example.com/hooks/xgh5ycrx1jf7mbs9w8z4c8e7ho \
  -H 'Content-Type: application/json' \
  -d '{"text": "Deploy of **api 2.4.0** finished :white_check_mark:"}'

The server answers 200 with the body ok. It also accepts application/x-www-form-urlencoded with the JSON in a payload field, as Slack's webhooks do, so tools made for Slack usually work unchanged.

FieldMeaning
textThe message, in Markdown. Required unless there are attachments. A text longer than the server's message limit is split into several messages.
channelAnother channel of the webhook's community, by name (ops-alerts or #ops-alerts), or @username for a direct message from the webhook's owner. Refused when the webhook is locked to its channel. The old name town-square still reaches the community's default channel, now called general.
username, icon_url, icon_emojiThe name and picture to show, when the server allows overrides.
attachmentsMessage attachments, drawn as cards (below).
propsExtra properties stored on the message, for apps that read them. Posting into a forum channel needs props.am_thread_title (1 to 100 characters): the hook is refused without one (see Forums take only topics).
typeA custom post type, such as custom_poll, for an app that draws it. Types starting with system_ are refused.
root_idThe ID of a thread's first message, to reply in that thread.
priorityMessage priority, for example {"priority": "urgent"}.
silenttrue sends the message without push notifications.

Every message a webhook posts carries a BOT tag next to its name, even when it shows under its creator's name, so people can tell it from a real person's message. Webhook messages never trigger outgoing webhooks.

Name and icon

A webhook posts as the person who created it, unless the server allows overrides. Two settings in System Console › Integrations › Integration Management, both off by default, change that:

  • Enable integrations to override usernames (ServiceSettings.EnablePostUsernameOverride): the webhook's Username or the request's username is shown instead; without either, the name is webhook.
  • Enable integrations to override profile picture icons (ServiceSettings.EnablePostIconOverride): the webhook's Profile Picture, or the request's icon_url or icon_emoji, is shown.

A value in the request wins over the one saved on the webhook.

Attachments

Attachments add structured content below the text. They follow Slack's message attachment format:

{
  "text": "Build failed on `main`",
  "attachments": [{
    "fallback": "main #1902 failed",
    "color": "#d24b4e",
    "author_name": "GitHub Actions",
    "author_icon": "https://chat.example.com/static/ci.png",
    "title": "main #1902 failed",
    "title_link": "https://ci.example.com/runs/1902",
    "text": "Commit `0f4d8e2` by Jonas Weber",
    "fields": [
      {"title": "Branch", "value": "zephyr-3.7", "short": true},
      {"title": "Duration", "value": "3m 40s", "short": true}
    ],
    "footer": "Lumen CI",
    "ts": 1760000000
  }]
}
FieldMeaning
fallbackPlain text for places that can't show the card, such as notifications.
colorThe colour bar: a hex colour, or good, warning, danger.
pretextText above the card.
author_name, author_link, author_iconA small header line.
title, title_linkThe card's title, optionally a link.
textThe card's body, in Markdown.
fieldsA list of {"title", "value", "short"}; short fields sit side by side.
image_url, thumb_urlA large image below, or a small one at the side.
footer, footer_icon, tsA footer line and a time (Unix seconds).
actionsButtons and menus: see Interactive messages.

Cards: stages and charts

Antimatter adds two fields to attachments, drawn by the default interface:

  • stages: a pipeline's steps as pills, up to 20. Each is {"name", "status"}, with a status of passed, failed, running, pending, skipped or canceled.
  • chart: a small line chart of recent values, {"values": [2 to 200 numbers], "threshold": number, "label": "…"}. The threshold is drawn as a dashed line, for an alert's limit.
{
  "attachments": [{
    "color": "danger",
    "title": "main #1902 failed",
    "stages": [
      {"name": "build", "status": "failed"},
      {"name": "unit", "status": "skipped"},
      {"name": "publish", "status": "skipped"}
    ]
  }, {
    "color": "warning",
    "title": "API latency above 400 ms",
    "chart": {"values": [210, 230, 260, 390, 450, 470], "threshold": 400, "label": "p95 latency, ms"}
  }]
}

The classic interface and other clients ignore these two fields, so put the essentials in text or fields too.

Create an outgoing webhook

An outgoing webhook watches the public channels of a community. It sends a message to your URL either when the message's first word matches one of its trigger words, or when someone picks it in the message's menu (a message action).

  1. In Integrations, choose Outgoing webhooks, then Add Outgoing Webhook.
  2. Fill in the Title and Description.
  3. Choose the Content Type of the request: application/x-www-form-urlencoded or application/json.
  4. Pick a Channel, enter Trigger Words (One Per Line), or both. With a channel and no trigger words, every message of that channel is sent. With both, the message must match both.
  5. Under Trigger When, choose First word matches a trigger word exactly, First word starts with a trigger word, or From the message menu. With From the message menu, the trigger words disappear, the Channel is optional (without one, the action is offered in every public channel of the community), and the Title is required: it is the action's name in the menu.
  6. Enter the Callback URLs (One Per Line). Each URL gets every matching message.
  7. Optionally set the Username and Profile Picture used for replies. For an action from the message menu, the Icon field replaces Profile Picture: the URL of a .png or .jpg image or, in Antimatter's default interface, an uploaded image. The menu shows it in grey; without one, the action gets a neutral icon.
  8. Choose Save and copy the token shown. Your service uses it to check that requests come from your server.

With the API: POST /api/v4/hooks/outgoing with {"team_id", "channel_id", "trigger_words", "trigger_when", "callback_urls", "content_type", "display_name"} (trigger_when: 0 for an exact match, 1 for "starts with", 2 for "from the message menu"). A webhook with trigger_when 2 takes no trigger words and needs a display_name. Upload its icon with POST /api/v4/hooks/outgoing/{hook_id}/icon (multipart, field image: PNG, JPEG or GIF, stored as a 128 px PNG); its icon_url then points at GET /api/v4/hooks/outgoing/{hook_id}/icon.

The outgoing webhook form with Trigger When set to From the message menu, and the Icon field below it
Trigger When set to From the message menu, with the Icon field.
If your service is on a private network, a system admin must allow its address in Allow untrusted internal connections to (ServiceSettings.AllowedUntrustedInternalConnections). The server doesn't call private addresses otherwise.

The outgoing request

Antimatter sends a POST with these fields, as JSON or form-encoded depending on the content type:

{
  "token": "6kd1ofo5ojr5zgu5ipfipybgnc",
  "team_id": "…",
  "team_domain": "lumen-labs",
  "channel_id": "…",
  "channel_name": "ops-alerts",
  "timestamp": 1760010000000,
  "user_id": "…",
  "user_name": "priya",
  "post_id": "…",
  "text": "deploy api 2.4.0",
  "trigger_word": "deploy",
  "file_ids": ""
}

Compare token with the webhook's token before doing anything. In the form-encoded version, timestamp is in seconds rather than milliseconds. The server waits for your answer for as long as Integration request timeout allows (30 seconds by default, ServiceSettings.OutgoingIntegrationRequestsTimeout).

Reply to an outgoing webhook

To post a reply, answer with JSON. To post nothing, answer with an empty body.

{
  "text": "Deploying **api 2.4.0** to staging…",
  "response_type": "comment",
  "username": "deploy-bot",
  "icon_url": "https://chat.example.com/static/deploy.png"
}
  • text and attachments: the reply. Without either, nothing is posted.
  • response_type: comment posts the reply in the triggering message's thread; otherwise it goes to the channel.
  • username, icon_url: shown when the server allows overrides; the webhook's own values are used otherwise.
  • props, type, priority: as for incoming webhooks.

The reply is posted as the webhook's creator, under the overridden name when there is one. For anything longer than a quick answer, answer at once and post later with an incoming webhook or the API.

Message actions

A message action is an outgoing webhook with Trigger When set to From the message menu. It never fires on posted messages. Instead, people see it in a message's menu: Message actions (⋯) › More actions, under the Integrations heading, with its icon. The classic interface doesn't show message actions. Picking it sends that one message to the callback URLs, and a short notice says Sent to the action's name, or shows your ephemeral_text.

  • Where it shows: in the webhook's channel, or, without a channel, in every public channel of its community. Only public channels have message actions.
  • Guests only get the actions bound to a channel they are in, not the community-wide ones.
  • Archived and read-only channels: the message can still be sent. Archiving a channel deletes the webhooks bound to it, so an archived channel keeps only the community-wide actions. An answer the person couldn't post there is shown to them alone.
  • Rate limit: each person can run 10 message actions a minute, after a burst of 5. Above that, the server answers 429.
  • Private networks: as for other outgoing webhooks, internal addresses must be allowed in Allow untrusted internal connections to.

The request

The payload is sent as JSON or form-encoded, as the webhook's content type says:

{
  "token": "6kd1ofo5ojr5zgu5ipfipybgnc",
  "trigger": "message_action",
  "team_id": "…",
  "team_domain": "lumen-labs",
  "channel_id": "…",
  "channel_name": "ops-alerts",
  "timestamp": 1760010000000,
  "user_id": "…",
  "user_name": "priya",
  "post_id": "…",
  "post_root_id": "",
  "post_create_at": 1760009000000,
  "post_user_id": "…",
  "post_user_name": "jonas",
  "permalink": "https://chat.example.com/lumen-labs/pl/…",
  "text": "The staging database is full again",
  "files": [{"id": "…", "name": "df.txt", "link": "https://chat.example.com/api/v4/files/…"}],
  "priority": "",
  "requested_ack": false,
  "burn_on_read": false
}
  • user_id and user_name are the person who picked the action; post_user_id and post_user_name the message's author.
  • Only that message is sent, never the rest of its thread.
  • files lists the message's files only when the person who picked the action may download them. Downloading a link needs a session or a token, like any file of the API.
  • A burn-on-read message is sent without its text and files, with burn_on_read set to true.
  • Form-encoded, the files come as repeated file_id, file_name and file_link fields, in the same order.

The answer

Answer as for any outgoing webhook (see Reply to an outgoing webhook): text or attachments posts a reply in the channel, and "response_type": "comment" posts it in the message's thread. You can also answer with ephemeral_text (cut to 300 characters): it is shown to the person who picked the action alone, in the notice. An empty body posts nothing.

{"ephemeral_text": "Ticket OPS-412 created"}

With the API

  • GET /api/v4/channels/{channel_id}/message_actions: the actions you may pick in a channel, with id, display_name, description and icon_url. Neither tokens nor callback URLs are returned.
  • POST /api/v4/posts/{post_id}/message_actions/{hook_id}: runs an action on a message you can read. It answers {"ephemeral_text"}.

When a webhook doesn't work

  • 501 from the hook URL: incoming webhooks are turned off on the server.
  • 400 "Failed to decode the payload…": the JSON is invalid, or the form field isn't named payload. "No text specified." means the request has neither text nor attachments.
  • 403: the webhook is locked to another channel, or its owner can no longer read the channel.
  • An outgoing webhook never fires: the channel is private, the trigger is not the first word, the message came from a webhook, or the webhook is set to trigger From the message menu.
  • A message action isn't in the menu: the channel is private, the action is bound to another channel, the person is a guest and the action has no channel, or outgoing webhooks are turned off.
  • 404 when running a message action: the action doesn't exist, or it doesn't cover that message's channel for that person. 429: the person ran more than 10 actions in a minute. 502: none of the callback URLs could be reached (the log has the reason).
  • A post into a forum is refused: the hook's message needs a title (props.am_thread_title); the error names the reason, such as app.post.forum.title_required.app_error, instead of a generic failure (see Channel types).
  • Logs: with Enable Webhook Debugging on (LogSettings.EnableWebhookDebugging), and the log level at DEBUG, the server logs the payloads of failed incoming requests. Failed outgoing calls are logged as Outgoing Webhook POST failed.