Mattermost Bot Setup and OpenClaw Configuration

Set up a self-hosted Mattermost bot with a bot token and WebSocket events, then wire it into OpenClaw. Covers install, base URL, config, and private network notes.

Read this when

  • Setting up Mattermost
  • Debugging Mattermost routing

Status: plugin available for download (bot token plus WebSocket events). Support covers channels, private channels, group DMs, and DMs. Mattermost is a team messaging platform you can host yourself (mattermost.com).

Install

npm registry

openclaw plugins install @openclaw/mattermost

Local checkout

openclaw plugins install ./path/to/local/mattermost-plugin

See Plugins for details.

Quick setup

Ensure plugin is available

Run the command above to install @openclaw/mattermost. Verify the application result before moving on.

Create a Mattermost bot

Set up a Mattermost bot account, then copy its bot token and invite the bot to whichever teams and channels it needs to read.

Copy the base URL

Grab the Mattermost base URL (for example, https://chat.example.com). Any trailing /api/v4 gets removed for you.

Configure OpenClaw and start the gateway

Smallest working config:

{
  channels: {
    mattermost: {
      enabled: true,
      botToken: "mm-token",
      baseUrl: "https://chat.example.com",
      dmPolicy: "pairing",
    },
  },
}

For non-interactive setups:

openclaw channels add --channel mattermost --bot-token <token> --http-url https://chat.example.com

Note

When Mattermost is self-hosted on a private, LAN, or tailnet address: outbound Mattermost API calls go through an SSRF guard that blocks private and internal IPs unless you opt in. Enable it with channels.mattermost.network.dangerouslyAllowPrivateNetwork: true (per account, use channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork).

Native slash commands

Native slash commands must be turned on explicitly. Once active, OpenClaw registers oc_* slash commands across every team the bot belongs to and accepts callback POSTs on the gateway HTTP server.

{
  channels: {
    mattermost: {
      commands: {
        native: true,
        nativeSkills: true,
        callbackPath: "/api/channels/mattermost/command",
        // Use when Mattermost cannot reach the gateway directly (reverse proxy/public URL).
        callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
      },
    },
  },
}

Commands registered: /oc_status, /oc_model, /oc_models, /oc_new, /oc_help, /oc_think, /oc_reasoning, /oc_verbose, /oc_queue. If nativeSkills: true is set, skill commands get registered as /oc_<skill> too.

Behavior notes

  • native and nativeSkills both default to "auto", and for Mattermost that resolves to disabled. Set them explicitly to true.
  • The default for callbackPath is /api/channels/mattermost/command.
  • When callbackUrl is left out, OpenClaw works out http://<gateway.customBindHost or localhost>:<gateway.port, default 18789><callbackPath> on its own. Wildcard bind hosts (0.0.0.0, ::) fall back to localhost.
  • In multi-account deployments, commands may live at the top level or beneath channels.mattermost.accounts.<id>.commands; account-level values take precedence over top-level fields.
  • Slash commands already registered by other integrations under the same trigger are not disturbed, since registration skips them. Commands the bot itself created are updated or recreated whenever the callback URL changes.
  • Validation of command callbacks uses the per-command tokens Mattermost returns when OpenClaw registers the oc_* commands.
  • Before accepting any callback, OpenClaw re-checks the current Mattermost command registration, so tokens left over from deleted or regenerated slash commands are rejected without needing a gateway restart.
  • If the Mattermost API cannot confirm a command is still current, callback validation fails closed. Failed validations are cached for a short time, concurrent lookups are merged, and fresh lookups are rate-limited per command to limit replay attempts.
  • Slash callbacks also fail closed when registration did not succeed, startup was incomplete, or the callback token does not match the registered token of the resolved command. A token that is valid for one command cannot pass upstream validation for another.
  • Accepted callbacks get an ephemeral "Processing..." acknowledgement, and the actual answer follows as an ordinary message.

Reachability requirement

The Mattermost server must be able to reach the callback endpoint.

  • Leave callbackUrl unset rather than localhost unless Mattermost shares a host or network namespace with OpenClaw.
  • Do not point callbackUrl at your Mattermost base URL unless that URL reverse-proxies /api/channels/mattermost/command through to OpenClaw.
  • To check quickly, use curl https://<gateway-host>/api/channels/mattermost/command; a GET should come back with 405 Method Not Allowed from OpenClaw, not 404.

Mattermost egress allowlist

When your callback targets private, tailnet, or internal addresses, add the callback host or domain to Mattermost's ServiceSettings.AllowedUntrustedInternalConnections.

Entries must be hosts or domains, never full URLs.

  • Correct: gateway.tailnet-name.ts.net
  • Incorrect: https://gateway.tailnet-name.ts.net

Environment variables (default account)

On the gateway host, use these env vars if that suits you better:

  • MATTERMOST_BOT_TOKEN=...
  • MATTERMOST_URL=https://chat.example.com

Note

Env vars take effect for the default account only (default). Any other account has to be configured through config values.

A workspace .env cannot set MATTERMOST_URL; refer to Workspace .env files.

Chat modes

Mattermost answers DMs on its own. What happens in channels is governed by chatmode:

oncall (default)

Replies in channels happen only when the bot is @mentioned.

onmessage

Every message posted in a channel gets a reply.

onchar

A trigger prefix at the start of a message causes a reply.

Config example:

{
  channels: {
    mattermost: {
      chatmode: "onchar",
      oncharPrefixes: [">", "!"], // default
    },
  },
}

Notes:

  • An explicit @mention always gets a response, even with onchar.
  • channels.mattermost.requireMention remains valid, though chatmode is the option to prefer. Settings for groups.<channelId>.requireMention on a single channel take priority over either one.
  • Once the bot has posted a visible reply inside a channel thread, subsequent messages in that thread no longer need a fresh @mention or an onchar prefix, which keeps multi-turn thread exchanges going. That participation memory lasts 7 days from the bot's last reply in the thread and survives gateway restarts. Threads where the bot has merely been a bystander are not affected; to require an explicit mention again, begin a new top-level message.
  • To keep participated-thread follow-ups from skipping mention gating, set channels.mattermost.implicitMentions.threadParticipation: false. For account-level overrides, use channels.mattermost.accounts.<id>.implicitMentions. Since Mattermost never emits replyToBot or quotedBot facts, neither flag does anything here.

Threading and sessions

Whether channel and group replies remain in the main channel or open a thread beneath the triggering post is decided by channels.mattermost.replyToMode.

  • off (default): a reply goes into a thread only if the inbound post already sits in one.
  • first: when a top-level channel/group post arrives, a thread opens under it and the conversation moves to a thread-scoped session.
  • all and batched: for Mattermost these behave the same as first, since follow-up chunks and media stay in the same thread once a thread root exists.
  • Direct messages fall back to off by default, even when replyToMode is configured.

To change the mode for direct, group, or channel chats, use channels.mattermost.replyToModeByChatType. Opt direct messages into threading by setting direct:

  • off (default): direct messages remain non-threaded within a single rolling session.
  • first, all, or batched: every top-level direct message opens a Mattermost thread backed by its own fresh session.
{
  channels: {
    mattermost: {
      replyToMode: "all",
      replyToModeByChatType: {
        direct: "first",
      },
    },
  },
}

Notes:

  • A thread-scoped session takes the triggering post id as its thread root.
  • first and all do the same thing, because follow-up chunks and media continue in the same thread once Mattermost has a thread root.
  • Overrides per chat type win over replyToMode. Where no direct override exists, deployments already in place keep flat, non-threaded DMs.
  • Following a restart or session reset, thread-scoped conversations pull recent messages from Mattermost whenever their pending history is cold. DMs with threading turned on are included; flat DMs behave as before. Sender access and context visibility are respected during recovery, the triggering message is left out, and the process is limited by historyLimit, a 200-post server window, and a five-second deadline. A short-lived server failure will not hold up the new message forever.

Access control (DMs)

  • Default: channels.mattermost.dmPolicy = "pairing" (a pairing code is issued to unknown senders). Other options: allowlist, open, disabled.
  • Approve through:
    • openclaw pairing list mattermost
    • openclaw pairing approve mattermost <CODE>
  • Public DMs: channels.mattermost.dmPolicy="open" together with channels.mattermost.allowFrom=["*"] (the wildcard is enforced by the config schema).
  • channels.mattermost.allowFrom takes user ids (recommended) as well as accessGroup:<name> entries. See Access groups.

Channels (groups)

  • Default: channels.mattermost.groupPolicy = "allowlist" (mention-gated).
  • Allowlist senders via channels.mattermost.groupAllowFrom (user IDs are recommended).
  • channels.mattermost.groupAllowFrom takes accessGroup:<name> entries. Refer to Access groups.
  • Per-channel mention overrides are configured under channels.mattermost.groups.<channelId>.requireMention, or channels.mattermost.groups["*"].requireMention for a default.
  • Text commands may come after the mention: @<bot-username> /new executes /new when commands.text is on (which is the default). A bare /new is run by Mattermost itself as a Mattermost slash command rather than being posted.
  • @username matching can change and is active only when channels.mattermost.dangerouslyAllowNameMatching: true.
  • Open channels: channels.mattermost.groupPolicy="open" (mention-gated).
  • Order of resolution: channels.mattermost.groupPolicy, followed by channels.defaults.groupPolicy, then "allowlist".
  • Runtime note: when the channels.mattermost section is absent entirely, group checks fail closed at runtime to groupPolicy="allowlist" (even if channels.defaults.groupPolicy is set), and a one-time warning is logged.

Example:

{
  channels: {
    mattermost: {
      groupPolicy: "open",
      groups: {
        "*": { requireMention: true },
        "team-channel-id": { requireMention: false },
      },
    },
  },
}

Targets for outbound delivery

Pair these target formats with openclaw message send or cron/webhooks:

TargetDelivers to
channel:<id>Channel by id
channel:<name> or #channel-nameChannel by name, searched across the teams the bot belongs to
user:<id> or mattermost:<id>DM with that user
@usernameDM (username resolved via the Mattermost API)

A single outbound message carries no more than one attachment; to send several files, issue separate sends.

Use channels.mattermost.mediaMaxMb to cap each inbound download and outbound attachment in MiB. accounts.<id>.mediaMaxMb takes precedence over the channel root, and agents.defaults.mediaMaxMb then provides the fallback. When no cap is configured, inbound downloads keep their 8 MiB default and outbound media keeps the shared loader defaults. Outbound images can be optimized. Once a cap is configured, a download or upload failure causes the send to fail rather than posting the unchecked original URL. With no cap configured, the existing URL fallback stays available.

Warning

In Mattermost, bare opaque IDs (such as 64ifufp...) are ambiguous (user ID vs channel ID).

OpenClaw resolves them user-first:

  • When the ID exists as a user (GET /api/v4/users/<id> succeeds), OpenClaw sends a DM, resolving the direct channel through /api/v4/channels/direct.
  • If not, the ID is handled as a channel ID.

For deterministic behavior, always use the explicit prefixes (user:<id> / channel:<id>).

DM channel retry

When a Mattermost DM target requires OpenClaw to resolve the direct channel first, transient direct-channel creation failures are retried by default.

Tune this behavior globally for the Mattermost plugin with channels.mattermost.dmChannelRetry, or per account with channels.mattermost.accounts.<id>.dmChannelRetry. Defaults:

{
  channels: {
    mattermost: {
      dmChannelRetry: {
        maxRetries: 3,
        initialDelayMs: 1000,
        maxDelayMs: 10000,
        timeoutMs: 30000,
      },
    },
  },
}

Notes:

  • Only DM channel creation (/api/v4/channels/direct) is affected, not every Mattermost API call.
  • Retries apply exponential backoff with jitter, covering transient failures like rate limits, 5xx responses, and network or timeout errors.
  • Client errors in the 4xx range other than 429 are considered permanent and are never retried.

Preview streaming

Mattermost streams thinking, tool activity, and partial reply text into a draft preview post that finalizes in place once the final answer is safe to send. Under partial mode, the preview updates on the same post id rather than flooding the channel with per-chunk messages. Under block mode, the preview rotates between completed text and tool-activity blocks, so earlier blocks remain visible as their own posts instead of being overwritten by the next one. Media/error finals cancel pending preview edits and fall back to normal delivery instead of flushing a throwaway preview post.

Preview streaming is on by default in partial mode. Configure it with channels.mattermost.streaming.mode (legacy scalar/boolean streaming values are migrated by openclaw doctor --fix):

{
  channels: {
    mattermost: {
      streaming: { mode: "partial" }, // off | partial | block | progress
    },
  },
}

Streaming modes

  • partial (default): a single preview post that is edited as the reply grows, then finalized with the complete answer.
  • block rotates the preview between completed text and tool-activity blocks, so each block remains visible as its own post rather than being overwritten in place. Parallel and consecutive tool updates share the current tool-activity post.
  • progress displays a status preview while generating and posts only the final answer at completion.
  • off turns preview streaming off. With streaming.block.enabled: true, completed assistant blocks are still delivered as normal block replies (separate posts) instead of one coalesced final post.

Streaming behavior notes

  • When a stream cannot be closed out in place, such as when the post is deleted partway through, OpenClaw sends a brand new final post instead, so the reply is never dropped.
  • Removing a plan clears a preview that would otherwise be empty; if a replacement plan is set, it receives a new preview even when the text has not changed. Once the turn finishes, any failed deletions of older previews are retried one more time, without touching the finalized reply. Should deletion keep failing, the stale preview can linger; the cleanup failure shows up in verbose logs.
  • Payloads that contain only thinking are kept out of channel posts, and that includes text arriving as a > Thinking blockquote. To see thinking on other surfaces, set /reasoning on; the Mattermost final post carries the answer alone.
  • The channel-mapping matrix lives in Streaming.

Read channel history (message tool)

Reading posts from a channel the configured Mattermost bot can reach is done with message action=read or the CLI:

openclaw message read --channel mattermost --target channel:<channelId> --limit 5 --json
  • Results come back in Mattermost's ordered post list and carry normalized timestampMs and timestampUtc fields.
  • limit starts at 60 and cannot exceed Mattermost's maximum of 200. For pagination, use before=<postId> or after=<postId>, but never both cursors at once.
  • Direct operator calls depend on Mattermost's channel membership and the read_channel permission. A 403 from the provider stays a normal, visible tool error.
  • The current account may perform delegated reads of the current Mattermost conversation. Reading across channels in a delegated way also demands the destination channel ID under channels.mattermost.groups, a "*" groups entry, or groupPolicy: "open". Delegated DM reads across accounts and channels fail closed.
  • History reads stay off unless enabled. Turn them on with channels.mattermost.actions.messages: true, and override the setting per account using channels.mattermost.accounts.<id>.actions.messages.
  • The same access rules govern the bundled plugin and the official plugin installed via npm or ClawHub. For delegated reads, the calling run and the plugin registration must both stay active.

Reactions (message tool)

  • Pair message action=react with channel=mattermost.
  • The Mattermost post id is messageId.
  • emoji takes names such as thumbsup or :+1: (colons may be omitted).
  • To take a reaction back, set remove=true (boolean).
  • Reaction add and remove events get forwarded to the routed agent session as system events, and the same DM/group policy checks used for messages apply.

Examples:

message action=react channel=mattermost target=channel:<channelId> messageId=<postId> emoji=thumbsup
message action=react channel=mattermost target=channel:<channelId> messageId=<postId> emoji=thumbsup remove=true

Config:

  • channels.mattermost.actions.reactions: turns reaction actions on or off (default true).
  • Per-account override: channels.mattermost.accounts.<id>.actions.reactions.

Interactive buttons (message tool)

Messages can carry clickable buttons. A click delivers the user's selection to the agent, which can then respond.

Buttons originate from the semantic presentation payload, both in ordinary agent replies and in message action=send. Value buttons become Mattermost interactive buttons, URL buttons stay visible in the message text, and select menus get downgraded to readable text.

Options offered by an ask_user question are rendered as buttons too, and tapping one answers that question right away. Typing an answer still works, and any option the Gateway does not index remains in the prose: the "Other…" choice, plus any prompt asking more than one question or whose question is multi-select, secret, or lacks two to four distinct options. Any other typed presentation action (command, callback, approval) remains readable text on Mattermost instead of turning into a button: a click there arrives at the agent as a message rather than executing the action, so a control would behave differently from its label.

message action=send channel=mattermost target=channel:<channelId> presentation={"blocks":[{"type":"buttons","buttons":[{"label":"Yes","value":"yes"},{"label":"No","value":"no"}]}]}

Presentation button fields:

  • label (string, required), Display label (alias: text).

  • value (string), Value returned on click, used as the action ID (aliases: callback_data, callbackData). Needed for a clickable button unless url is set.

  • url (string), Link button; shown as label: url text in the message body rather than as an interactive button.

  • style (primary" | "secondary" | "success" | "danger), Button style. Values Mattermost does not support get its default styling.

To announce button support in the agent system prompt, add inlineButtons to the channel capabilities:

{
  channels: {
    mattermost: {
      capabilities: ["inlineButtons"],
    },
  },
}

When a button is clicked:

Access check

Whoever clicks must clear the same DM/group policy checks as a message sender; clicks that fail get an ephemeral notice and are discarded.

Buttons replaced with confirmation

Every button is swapped for a confirmation line (for example, "✓ Yes selected by @user").

Agent receives the selection

The selection reaches the agent as an inbound message (along with a system event), and the agent responds.

Implementation notes

  • Button callbacks are verified with HMAC-SHA256 (automatic, no config required).
  • A click replaces the entire attachment block, so all buttons disappear at once; removing only some is not possible.
  • Action IDs that contain hyphens or underscores get sanitized automatically (a Mattermost routing limitation).
  • If a click's action_id matches no action on the original post, it is rejected with 403 ("Unknown action").

Config and reachability

  • channels.mattermost.capabilities: an array of capability strings. To turn on the buttons tool description inside the agent system prompt, add "inlineButtons".
  • channels.mattermost.interactions.callbackBaseUrl: an optional external base URL used for button callbacks (such as https://gateway.example.com). Set this when the gateway's bind host is not directly reachable from Mattermost.
  • When running several accounts, the same field can be set under channels.mattermost.accounts.<id>.interactions.callbackBaseUrl as well.
  • Should interactions.callbackBaseUrl be left out, OpenClaw builds the callback URL from gateway.customBindHost + gateway.port (default 18789), then falls back to http://localhost:<port>. The callback path is /mattermost/interactions/<accountId>.
  • Reachability requirement: the Mattermost server must be able to reach the button callback URL. localhost only works when Mattermost and OpenClaw share the same host or network namespace.
  • channels.mattermost.interactions.allowedSourceIps: a source-IP allowlist for button callbacks. With it unset, only loopback sources (127.0.0.1, ::1) pass, so any remote Mattermost server has to be allowlisted here, otherwise its clicks come back rejected with 403. When a reverse proxy sits in front, set gateway.trustedProxies too so the real client IP is taken from forwarded headers.
  • If the callback target lives on a private, tailnet, or internal network, add its host/domain to Mattermost ServiceSettings.AllowedUntrustedInternalConnections.

Direct API integration (external scripts)

Rather than routing through the agent's message tool, external scripts and webhooks can publish buttons straight through the Mattermost REST API. OpenClaw's message tool is the preferred route. For direct integrations, import buildButtonAttachments from @openclaw/mattermost/api.js; when posting raw JSON instead, stick to these rules:

Payload structure:

{
  channel_id: "<channelId>",
  message: "Choose an option:",
  props: {
    attachments: [
      {
        actions: [
          {
            id: "mybutton01", // alphanumeric only - see below
            type: "button", // required, or clicks are silently ignored
            name: "Approve", // display label
            style: "primary", // optional: "default", "primary", "danger"
            integration: {
              url: "https://gateway.example.com/mattermost/interactions/default",
              context: {
                action_id: "mybutton01", // must match button id
                action: "approve",
                // ... any custom fields ...
                _token: "<hmac>", // see HMAC section below
              },
            },
          },
        ],
      },
    ],
  },
}

Warning

Critical rules

  1. Attachments belong in props.attachments, not at the top level attachments (which is silently ignored).
  2. Each action requires type: "button" - omit it and clicks vanish without any error.
  3. Each action requires an id field - actions lacking IDs are ignored by Mattermost.
  4. The action id must be alphanumeric only ([a-zA-Z0-9]). Underscores and hyphens break Mattermost's server-side action routing (a 404 comes back). Remove them beforehand.
  5. context.action_id has to match the button's id; any click whose action_id is not present on the post gets refused by the gateway.
  6. context.action_id is mandatory - without it the interaction handler responds with 400.
  7. The callback source IP must be allowed (see interactions.allowedSourceIps above).

HMAC token generation

Button clicks are verified by the gateway using HMAC-SHA256. Tokens produced by external scripts have to match the gateway's verification logic:

Derive the secret from the bot token

HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken), hex-encoded.

Build the context object

Assemble the context object from every field except _token.

Serialize with sorted keys

Serialize using recursively sorted keys and no spaces (nested objects are canonicalized by the gateway as well, yielding compact JSON).

Sign the payload

HMAC-SHA256(key=secret, data=serializedContext)

Add the token

Place the resulting hex digest into the context as _token.

Python example:

import hmac, hashlib, json

secret = hmac.new(
    b"openclaw-mattermost-interactions",
    bot_token.encode(), hashlib.sha256
).hexdigest()

ctx = {"action_id": "mybutton01", "action": "approve"}
payload = json.dumps(ctx, sort_keys=True, separators=(",", ":"))
token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()

context = {**ctx, "_token": token}

Common HMAC pitfalls

  • By default Python's json.dumps inserts spaces ({"key": "val"}). Pass separators=(",", ":") so the output is compact like JavaScript's ({"key":"val"}).
  • Sign every context field (_token excluded). The gateway removes _token and then signs whatever remains. Signing only some fields leads to a silent verification failure.
  • Use sort_keys=True - keys are sorted by the gateway before signing, and Mattermost may reorder context fields while storing the payload.
  • The secret should be derived from the bot token (deterministically), not from random bytes. Both the process creating buttons and the gateway verifying them must use the same secret.

Directory adapter

A directory adapter ships with the Mattermost plugin, resolving channel and user names through the Mattermost API. Thanks to it, #channel-name and @username targets work in openclaw message send and in cron/webhook deliveries.

Nothing needs configuring - the adapter pulls the bot token from the account config.

Multi-account

Several accounts can be configured under channels.mattermost.accounts:

{
  channels: {
    mattermost: {
      accounts: {
        default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
        alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" },
      },
    },
  },
}

Values set on an account take precedence over top-level fields; when no account is named, channels.mattermost.defaultAccount determines which one gets used.

Troubleshooting

No replies in channels

Make sure the bot has joined the channel, then either mention it (oncall), apply a trigger prefix (onchar), or configure chatmode: "onmessage".

Auth or multi-account errors

  • Verify the bot token, the base URL, and whether the account is turned on.
  • Trouble with multiple accounts: environment variables only take effect for the default account.
  • Mattermost hosts on a private network or LAN require network.dangerouslyAllowPrivateNetwork: true (by default the SSRF guard rejects private IPs).

Native slash commands fail

  • Unauthorized: invalid command token.: the callback token was not accepted by OpenClaw. Usual reasons:
    • slash command registration did not succeed, or finished only partly, during startup
    • the callback is reaching the wrong gateway or account
    • stale commands in Mattermost still point at an earlier callback target
    • the gateway was restarted but slash commands were not reactivated
  • HTTP 429 when under load: callbacks that rely on an outgoing OAuth connection, or that pass through a proxy which drops the Authorization header, carry the command token in the body only. Such requests draw from the same limited anonymous pool and may be throttled once it fills up. Keep Mattermost's original Authorization: Token header intact through proxies so that recognized command credentials get their own capacity.
  • Should native slash commands stop responding, search the logs for mattermost: failed to register slash commands or mattermost: native slash commands enabled but no commands could be registered.
  • When callbackUrl is left out and the logs warn that the callback resolved to a loopback URL such as http://localhost:18789/..., that address is likely reachable only if Mattermost shares a host or network namespace with OpenClaw. Configure an explicit commands.callbackUrl that can be reached from outside instead.

Buttons issues

  • Buttons show up as white boxes, or not at all: the button data is not well formed. Every presentation button requires both a label and a value (any button lacking one of them is discarded).
  • Buttons render, but clicking does nothing: confirm the gateway can be reached from the Mattermost server, that the Mattermost server IP appears in channels.mattermost.interactions.allowedSourceIps (without it, only loopback is allowed), and that ServiceSettings.AllowedUntrustedInternalConnections contains the callback host for private targets.
  • Buttons give a 404 when clicked: the button id most likely has hyphens or underscores in it. Mattermost's action router fails on IDs that are not alphanumeric. Stick to [a-zA-Z0-9].
  • Gateway logs rejected callback source: the click originated from an IP not covered by interactions.allowedSourceIps. Add the Mattermost server or your ingress to the allowlist, and set gateway.trustedProxies when running behind a reverse proxy.
  • Gateway logs invalid _token: HMAC mismatch. Make sure every context field is signed (not just some), that keys are sorted, and that the JSON is compact (no spaces). Refer to the HMAC section above.
  • Gateway logs missing _token in context: the button's context is missing the _token field. Include it while assembling the integration payload.
  • Gateway rejects the click with Unknown action: no action id on the post matches context.action_id. Assign the same sanitized value to both.
  • Agent offers no buttons: append capabilities: ["inlineButtons"] to the Mattermost channel config.
4,299 words · updated Sep 18, 2026