Triggering CI/CD from Mattermost with Interactive Message Buttons
Mattermost's built-in interactive message buttons let you trigger CI/CD jobs from chat. Here's how the click payload and token verification actually work, with a Jenkins example.
05 Sept 2025, 20:49 UTC

The problem: starting a pipeline without leaving the conversation
A release decision usually happens in chat. Someone says “looks good, ship it to staging,” and then a human has to context-switch to a CI dashboard, find the right job, and click the right button. That gap is where steps get skipped or the wrong job gets triggered.
Mattermost already ships a mechanism for closing that gap: interactive message buttons. A post can carry buttons; when a user clicks one, Mattermost sends an HTTP POST to a URL you control, and your service can run the workflow and post a result back to the channel. No extra plugin is required — this is part of the core integrations feature set (custom integrations must be enabled in the System Console).
How interactive buttons actually work
You attach a button to a post using the attachments field with an actions array. Each action has an integration object containing a url (your endpoint) and a context object (arbitrary key/value data you want sent back on click).
When a user clicks the button, Mattermost POSTs a JSON body to that URL. The documented payload includes fields such as user_id, user_name, channel_id, team_id, post_id, your context values, a trigger_id (used to open interactive dialogs), and a token.
That token is the security piece, and it is important to be precise about it: Mattermost generates a token per outgoing webhook or slash command, and for button posts created through those integrations the same token is included in the click payload. Your endpoint should compare it against the token Mattermost showed you when you created the webhook, and reject mismatches. This is a shared-token check — not an HMAC signature header — so treat the token like a password and serve the endpoint over HTTPS only.
Your endpoint must respond quickly. You can return a JSON body with update (to edit the original message, e.g. replace the button with “Deploying…”) and/or ephemeral_text (a message only the clicker sees). For anything slow — like a CI job that takes minutes — acknowledge immediately and report the result later via an incoming webhook.
Worked example: a “Deploy to Staging” button
The example below targets a recent Mattermost server (v9/v10 era); the interactive message format has been stable for years, but verify field names against your server’s API docs since details can change between releases.
1. Create the tokens
In Mattermost, go to Main Menu → Integrations (requires a role with integration permissions, granted in System Console → Integrations → Integration Management):
- Create an incoming webhook for the target channel. Save its URL — your service uses it to post results.
- Create an outgoing webhook or slash command and note its token. Posts created with this token can carry buttons whose click payloads your service can trust.
2. Post a message with a button
The simplest path is an incoming webhook post with attachments (run from any shell; no special permissions needed beyond the webhook URL):
curl -X POST 'https://mattermost.example.com/hooks/INCOMING_WEBHOOK_ID' \
-H 'Content-Type: application/json' \
-d '{
"channel": "town-square",
"username": "deploy-bot",
"text": "Release 1.4.2 is ready for staging.",
"attachments": [{
"text": "Click to deploy this build to the staging environment.",
"actions": [{
"name": "Deploy to Staging",
"integration": {
"url": "https://actions.example.com/mattermost/deploy",
"context": {"release": "1.4.2", "env": "staging"}
}
}]
}]
}'
Note: buttons on incoming-webhook posts are clickable by anyone who can see the channel, and the click payload’s token will only match if the post was created with a token-bearing integration. If you need per-user authorization, check user_id in the payload against an allowlist in your service.
3. The receiving service
This minimal Flask service (Python 3.9+, pip install flask requests) verifies the token, acknowledges the click, triggers a Jenkins build, and reports back. Run it behind a TLS-terminating reverse proxy; the URL must be reachable from the Mattermost server.
import os, threading, requests
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
EXPECTED_TOKEN = os.environ['MM_BUTTON_TOKEN'] # token from step 1
RESULT_HOOK = os.environ['MM_RESULT_HOOK'] # incoming webhook URL
JENKINS_URL = os.environ['JENKINS_BUILD_URL'] # e.g. .../job/deploy-staging/build
JENKINS_AUTH = (os.environ['JENKINS_USER'], os.environ['JENKINS_API_TOKEN'])
def run_deploy(release, user, channel_id):
try:
r = requests.post(JENKINS_URL, auth=JENKINS_AUTH,
params={'token': os.environ['JENKINS_JOB_TOKEN'],
'RELEASE': release}, timeout=10)
ok = r.status_code in (200, 201)
text = (f"Deploy of {release} to staging started by @{user}."
if ok else f"Deploy of {release} failed: Jenkins returned {r.status_code}.")
except Exception as e:
text = f"Deploy of {release} failed: {e}"
requests.post(RESULT_HOOK, json={'channel': channel_id, 'text': text}, timeout=10)
@app.route('/mattermost/deploy', methods=['POST'])
def deploy():
data = request.get_json(force=True)
if data.get('token') != EXPECTED_TOKEN:
abort(401)
release = (data.get('context') or {}).get('release', 'unknown')
threading.Thread(target=run_deploy, args=(
release, data.get('user_name'), data.get('channel_id'))).start()
# Immediate acknowledgement: update the original post so the button is replaced
return jsonify({'update': {'message': f'Deploy of {release} to staging requested…'}})
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080)
Start it with the environment variables set, then click the button in Mattermost. Expected result: the original message updates to “requested…”, and a few seconds later a new message from the incoming webhook reports success or the Jenkins HTTP status. If nothing happens, check in order: service logs for a 401 (token mismatch), your reverse proxy logs (TLS or reachability), and Mattermost’s server logs for outbound request errors.
Trade-offs and limitations
- Token, not signature. The token proves the request came through Mattermost but does not cryptographically sign the body. Keep the endpoint HTTPS-only, keep the token secret, and rotate it if leaked.
- No built-in retries. If your endpoint is down when the button is clicked, the user sees an error and Mattermost does not queue a retry. For critical workflows, make the endpoint do minimal work (validate, enqueue, acknowledge) and let a worker handle the rest idempotently.
- Limited context. The payload carries identifiers and your
contextvalues. If you need the user to supply parameters at click time, usetrigger_idto open an interactive dialog instead of firing the job directly. - Authorization is your job. Any channel member can click. Validate
user_idoruser_nameserver-side before triggering anything destructive.
Next steps
- Stand up a test server (the official Docker preview image works) and enable integrations in the System Console.
- Create the incoming webhook and a token-bearing integration; post the button message above.
- Point the button at a logging echo endpoint first and confirm the payload fields and token match what your server version sends.
- Swap in the real CI trigger, add a user allowlist, and move slow work to a background queue.
The result is a small, auditable path from “approved in chat” to “running in CI,” with the decision and the outcome recorded in the same channel.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.