DEV Community

Cover image for Solved: Build a Custom Approval Workflow using Slack Buttons and Python
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: Build a Custom Approval Workflow using Slack Buttons and Python

🚀 Executive Summary

TL;DR: Manual approval processes are often slow, error-prone, and expensive with rigid SaaS solutions. This guide demonstrates building a flexible, custom approval workflow using Slack buttons and Python, integrating directly into team communication for rapid, cost-effective decision-making.

🎯 Key Takeaways

  • A custom approval workflow leverages Slack’s Interactivity & Shortcuts, requiring a publicly accessible endpoint (e.g., ngrok, AWS Lambda) for Slack to send interaction payloads.
  • Security is paramount, implemented via Slack’s Signing Secret and HMAC-SHA256 verification in the Python Flask backend to ensure incoming requests are legitimate.
  • Slack’s Block Kit is used to construct interactive messages with ‘Approve’ and ‘Deny’ buttons, and the slack\_client.chat\_update method modifies the original message to reflect decisions, providing an audit trail.

Build a Custom Approval Workflow using Slack Buttons and Python

As DevOps engineers, we constantly seek ways to streamline processes, eliminate manual toil, and integrate our toolchains for maximum efficiency. One common bottleneck in many organizations is the approval process. Whether it’s for deploying code, granting access, or provisioning resources, manual approvals can be slow, prone to human error, and disrupt the fast-paced development cycle. Existing SaaS solutions often come with a hefty price tag or offer rigid workflows that don’t quite fit your unique organizational needs.

What if you could build a flexible, custom approval system right where your team already communicates? In this tutorial, we’ll guide you through building a custom approval workflow using Slack buttons and Python. This approach leverages Slack’s interactivity features to provide a rapid, integrated, and cost-effective solution for your approval needs, giving you full control over the logic and integration points.

Prerequisites

Before we dive into the build, ensure you have the following:

  • Python 3.8+: Our backend logic will be written in Python.
  • pip: Python’s package installer.
  • A Slack Workspace: With administrative access to create and manage Slack Apps.
  • A Publicly Accessible Endpoint: Slack needs to send interaction payloads to your server. For local development, ngrok is an excellent choice. For production, consider a cloud-based solution like AWS Lambda, Google Cloud Run, or a dedicated VM.
  • Basic understanding of webhooks and API interactions: Familiarity with HTTP methods (GET, POST) and JSON payloads will be beneficial.

Step-by-Step Guide

Our workflow will involve three main components:

  1. A Slack App to handle interactivity.
  2. A Python Flask server to receive and process Slack interactions.
  3. A Python script to initiate the approval request by sending a message with buttons to Slack.

Step 1: Set up Your Slack App

The first step is to create and configure a Slack App to facilitate communication and interactivity.

  1. Navigate to api.slack.com/apps and click “Create New App”. Choose “From scratch”, give it a name (e.g., “Approval Bot”), and select your workspace.
  2. Enable Interactivity & Shortcuts:
    • In the left sidebar, click “Interactivity & Shortcuts”.
    • Toggle “Interactivity” to On.
    • For the “Request URL”, enter your publicly accessible endpoint followed by /slack/events. If using ngrok, it might look like https://<your-ngrok-id>.ngrok.io/slack/events. We’ll set up this endpoint in our Python server later.
    • Click “Save Changes”.
  3. Set OAuth & Permissions:
    • In the left sidebar, click “OAuth & Permissions”.
    • Under “Scopes” > “Bot Token Scopes”, add the following scopes:
      • chat:write: To send messages.
      • commands: If you plan to use Slack slash commands (optional for this specific tutorial, but often useful).
      • app_mentions:read: To read messages where your bot is mentioned.
    • Click “Save Changes”.
  4. Install the App to Your Workspace:
    • Scroll to the top of the “OAuth & Permissions” page.
    • Click “Install to Workspace”. Follow the prompts to authorize the app.
    • Once installed, you’ll see a “Bot User OAuth Token” (starts with xoxb-). Copy this token; you’ll need it for your Python script to send messages.
  5. Get Your Signing Secret:
    • In the left sidebar, click “Basic Information”.
    • Scroll down to “App Credentials”. Copy your “Signing Secret”. This is crucial for verifying that incoming requests to your server genuinely originate from Slack.

Step 2: Develop the Python Backend for Interactive Messages

Now, let’s create a Python Flask application that listens for interactive payloads from Slack and processes the button clicks. Install the necessary libraries:

pip install Flask requests python-dotenv slack_sdk
Enter fullscreen mode Exit fullscreen mode

Create a file named app.py:

import os
import json
import hmac
import hashlib
from flask import Flask, request, jsonify, abort
from dotenv import load_dotenv
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

load_dotenv() # Load environment variables from .env file

app = Flask(__name__)

# Load Slack credentials from environment variables
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET")
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN")

if not SLACK_SIGNING_SECRET or not SLACK_BOT_TOKEN:
    print("ERROR: Missing SLACK_SIGNING_SECRET or SLACK_BOT_TOKEN environment variables.")
    exit(1)

slack_client = WebClient(token=SLACK_BOT_TOKEN)

# Function to verify Slack requests
def verify_slack_request(req):
    timestamp = req.headers.get("X-Slack-Request-Timestamp")
    if not timestamp:
        return False

    req_body = req.get_data().decode('utf-8')
    sig_basestring = f"v0:{timestamp}:{req_body}"

    my_signature = "v0=" + hmac.new(
        SLACK_SIGNING_SECRET.encode("utf-8"),
        sig_basestring.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    slack_signature = req.headers.get("X-Slack-Signature")

    return hmac.compare_digest(my_signature, slack_signature)

@app.route("/slack/events", methods=["POST"])
def slack_events():
    # Verify the request originates from Slack
    if not verify_slack_request(request):
        abort(403) # Forbidden

    payload = request.form.get("payload")
    if not payload:
        abort(400) # Bad Request

    data = json.loads(payload)

    # Handle URL Verification Challenge
    if data.get("type") == "url_verification":
        return jsonify({"challenge": data.get("challenge")})

    # Process block_actions (button clicks)
    if data.get("type") == "block_actions":
        action = data["actions"][0]
        action_id = action.get("action_id")
        user_id = data["user"]["id"]
        channel_id = data["channel"]["id"]
        message_ts = data["message"]["ts"] # Timestamp of the original message

        print(f"User {user_id} clicked {action_id} in channel {channel_id}")

        response_text = ""
        if action_id == "approve_button":
            response_text = f"Approval granted by <@{user_id}>!"
            # Add your approval logic here (e.g., call another API, update a database)
            print("Approval logic triggered!")
        elif action_id == "deny_button":
            response_text = f"Approval denied by <@{user_id}>."
            # Add your denial logic here
            print("Denial logic triggered!")
        else:
            response_text = "Unknown action."

        # Update the original message to reflect the decision
        try:
            slack_client.chat_update(
                channel=channel_id,
                ts=message_ts,
                text=response_text,
                blocks=[
                    {
                        "type": "section",
                        "text": {
                            "type": "mrkdwn",
                            "text": response_text
                        }
                    }
                ]
            )
        except SlackApiError as e:
            print(f"Error updating Slack message: {e.response['error']}")

        return "", 200 # Slack expects an empty 200 OK response quickly

    return "", 200 # Acknowledge other types of events

if __name__ == "__main__":
    app.run(port=5000)
Enter fullscreen mode Exit fullscreen mode

Create a .env file in the same directory as app.py with your Slack credentials:

SLACK_SIGNING_SECRET=your_slack_signing_secret_here
SLACK_BOT_TOKEN=xoxb-your_bot_user_oauth_token_here
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • We use Flask to create a simple web server.
  • The /slack/events route is configured to receive POST requests from Slack.
  • The verify_slack_request function is critical for security, ensuring that incoming requests are legitimate Slack events by checking the signature header.
  • We parse the payload to identify block_actions, which are triggered by button clicks.
  • Based on the action_id (approve_button or deny_button), we can implement our specific approval or denial logic.
  • After processing, we use slack_client.chat_update to modify the original Slack message, showing who made the decision and what the outcome was. This prevents multiple users from acting on the same request and provides a clear audit trail.

Run your Flask app:

python app.py
Enter fullscreen mode Exit fullscreen mode

If you’re developing locally, start ngrok to expose your Flask app to the internet:

ngrok http 5000
Enter fullscreen mode Exit fullscreen mode

Make sure the ngrok URL (e.g., https://<your-ngrok-id>.ngrok.io/slack/events) matches the Request URL you configured in your Slack App settings.

Step 3: Sending the Approval Request to Slack

Now let’s create a script that initiates an approval request by sending an interactive message with buttons to a specified Slack channel. Create a file named send_approval.py:

import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from dotenv import load_dotenv

load_dotenv()

SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN")
TARGET_CHANNEL = "#devops-approvals" # Replace with your target Slack channel ID or name

if not SLACK_BOT_TOKEN:
    print("ERROR: Missing SLACK_BOT_TOKEN environment variable.")
    exit(1)

slack_client = WebClient(token=SLACK_BOT_TOKEN)

def send_approval_request(
    requester_name: str, 
    request_details: str, 
    channel: str = TARGET_CHANNEL
):
    try:
        response = slack_client.chat_postMessage(
            channel=channel,
            text=f"New approval request from {requester_name}",
            blocks=[
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"

### Approval Request from {requester_name}

\n\n{request_details}"
                    }
                },
                {
                    "type": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "text": {
                                "type": "plain_text",
                                "text": "Approve"
                            },
                            "style": "primary",
                            "value": "approved", # This value can be used if you don't use action_id
                            "action_id": "approve_button" # Unique ID for this action
                        },
                        {
                            "type": "button",
                            "text": {
                                "type": "plain_text",
                                "text": "Deny"
                            },
                            "style": "danger",
                            "value": "denied",
                            "action_id": "deny_button"
                        }
                    ]
                }
            ]
        )
        print(f"Approval request sent to {channel}. Message timestamp: {response['ts']}")
        return response
    except SlackApiError as e:
        print(f"Error sending message to Slack: {e.response['error']}")
        return None

if __name__ == "__main__":
    send_approval_request(
        requester_name="John Doe",
        request_details="Please approve the deployment of feature X to production."
    )
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • This script uses the slack_sdk library to interact with the Slack Web API.
  • We construct a message using Slack’s Block Kit, specifically using section blocks for text and actions blocks for buttons.
  • Each button has an action_id (e.g., approve_button, deny_button) which our Flask backend will use to identify the action taken.
  • The chat_postMessage method sends this interactive message to the specified TARGET_CHANNEL. Make sure your bot is a member of this channel.

Run this script to send an approval request to Slack:

python send_approval.py
Enter fullscreen mode Exit fullscreen mode

You should see a new message in your Slack channel with “Approve” and “Deny” buttons.

Step 4: Process the Approval Decision and Trigger Actions

This step primarily happens within your app.py from Step 2. When a user clicks a button in Slack, the /slack/events endpoint is hit. Our Flask app then:

  1. Verifies the request’s authenticity using the signing secret.
  2. Parses the interaction payload to determine which button was clicked (approve_button or deny_button).
  3. Executes custom logic based on the decision. This is where you would integrate with other systems.
  4. Updates the original Slack message to confirm the action, providing real-time feedback and an audit trail.

Example Integration (within app.py):

Inside the if action_id == "approve_button": or if action_id == "deny_button": blocks, you can add code to:

  • Call an external API: Trigger a CI/CD pipeline, provision resources in a cloud provider, or update a ticket in an ITSM system.
  • Update a database: Log the approval decision, who made it, and when.
  • Send further notifications: Alert other teams or systems about the outcome.

For instance, an approval might trigger a Jenkins job or an AWS Lambda function:

# Inside app.py, under the 'if action_id == "approve_button":' block
import requests

# ... existing code ...

        if action_id == "approve_button":
            response_text = f"Approval granted by <@{user_id}>! Initiating deployment..."
            try:
                # Example: Triggering a Jenkins job (replace with your actual API call)
                jenkins_url = "http://your-jenkins-server/job/your-deployment-job/buildWithParameters"
                jenkins_params = {"PARAM1": "value", "MESSAGE_TS": message_ts} # Pass relevant info
                jenkins_headers = {"Authorization": "Basic BASE64_ENCODED_USER:API_TOKEN"} # Use env vars for credentials
                jenkins_response = requests.post(jenkins_url, params=jenkins_params, headers=jenkins_headers)
                jenkins_response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
                print(f"Jenkins job triggered successfully: {jenkins_response.status_code}")
                response_text += "\n_Deployment initiated successfully._"
            except requests.exceptions.RequestException as e:
                print(f"Error triggering Jenkins job: {e}")
                response_text += "\n_Failed to initiate deployment._"
        elif action_id == "deny_button":
            response_text = f"Approval denied by <@{user_id}>. Deployment cancelled."
            # Logic to cancel a pending action, if applicable
            print("Denial logic triggered!")
# ... rest of app.py
Enter fullscreen mode Exit fullscreen mode

Remember to handle credentials securely using environment variables for any external API calls.

Common Pitfalls

When building custom integrations, you might encounter a few common issues:

  • Slack Signing Secret Verification Errors (403 Forbidden): This is often due to an incorrect SLACK_SIGNING_SECRET in your .env file, or an issue with the signature verification logic itself. Double-check your secret and ensure the timestamp and request body are handled precisely as Slack expects.
  • Missing OAuth Scopes: If your bot isn’t performing actions (e.g., sending messages) as expected, verify that you’ve granted all necessary OAuth Bot Token Scopes (like chat:write, app_mentions:read) in your Slack App settings and reinstalled the app to apply changes.
  • Public Endpoint Accessibility: Slack needs to be able to reach your Request URL. If your server isn’t publicly accessible (e.g., behind a firewall without proper port forwarding, or ngrok isn’t running), Slack won’t be able to send payloads, leading to timeouts or errors.
  • Rate Limiting: Slack APIs have rate limits. If your backend makes many rapid calls, you might hit these limits. Implement exponential backoff for retries to handle this gracefully. For approval workflows, this is less common unless you’re updating many messages or channels simultaneously.

Conclusion

By following this tutorial, you’ve learned how to build a powerful and flexible custom approval workflow using Slack buttons and Python. This approach empowers your team to make quick decisions directly within their communication hub, reducing delays and enhancing productivity. You now have a solid foundation to:

  • Integrate with your CI/CD pipelines for deployment approvals.
  • Manage access requests for sensitive systems.
  • Approve infrastructure changes or resource provisioning.
  • Create custom workflows tailored to your organization’s unique processes.

The beauty of this solution lies in its adaptability. You can extend the Python backend to interact with virtually any API or system, turning Slack into a central command center for your operational approvals. Explore Slack’s rich API documentation and Block Kit to further customize your messages and interactive elements, unlocking even more possibilities for automation and efficiency.


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)