Systems Library / Operations & Admin / How to Automate Multi-Step Approval Workflows
Operations & Admin process workflow

How to Automate Multi-Step Approval Workflows

Build approval chains that route through the right people automatically.

Jay Banlasan

Jay Banlasan

The AI Systems Guy

Manual approval chains stall because someone forgets to forward an email. I built a system to automate multi-step approval workflows that routes requests through the right people in the right order, sends reminders, escalates when stalled, and logs every decision.

No more "who has this request?" conversations.

What You Need Before Starting

Step 1: Define Approval Chains

APPROVAL_CHAINS = {
    "expense_under_1000": {
        "steps": [
            {"role": "direct_manager", "action": "approve_or_reject"},
        ],
        "timeout_hours": 48,
        "escalation": "department_head"
    },
    "expense_over_1000": {
        "steps": [
            {"role": "direct_manager", "action": "approve_or_reject"},
            {"role": "finance_director", "action": "approve_or_reject"},
        ],
        "timeout_hours": 72,
        "escalation": "cfo"
    },
    "new_vendor": {
        "steps": [
            {"role": "department_head", "action": "approve_or_reject"},
            {"role": "legal", "action": "review_terms"},
            {"role": "finance_director", "action": "approve_or_reject"},
        ],
        "timeout_hours": 120,
        "escalation": "coo"
    }
}

Step 2: Create the Request Tracker

import sqlite3
from datetime import datetime

def init_approval_db(db_path="approvals.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS approval_requests (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            request_type TEXT,
            requester TEXT,
            details TEXT,
            current_step INTEGER DEFAULT 0,
            status TEXT DEFAULT 'pending',
            created_at TEXT,
            completed_at TEXT
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS approval_actions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            request_id INTEGER,
            step_number INTEGER,
            approver_email TEXT,
            action TEXT,
            comments TEXT,
            acted_at TEXT
        )
    """)
    conn.commit()
    return conn

Step 3: Submit and Route Requests

def submit_request(conn, request_type, requester, details):
    conn.execute(
        "INSERT INTO approval_requests (request_type, requester, details, created_at) VALUES (?,?,?,?)",
        (request_type, requester, details, datetime.now().isoformat())
    )
    conn.commit()
    request_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]

    chain = APPROVAL_CHAINS.get(request_type)
    if chain and chain["steps"]:
        first_approver = chain["steps"][0]
        notify_approver(first_approver["role"], request_id, details)

    return request_id

def notify_approver(role, request_id, details):
    print(f"Notifying {role} about request #{request_id}: {details[:100]}")

Step 4: Process Approvals and Advance

def process_approval(conn, request_id, approver_email, action, comments=""):
    request = conn.execute(
        "SELECT request_type, current_step, status FROM approval_requests WHERE id=?",
        (request_id,)
    ).fetchone()

    if request[2] != "pending":
        return {"error": "Request is no longer pending"}

    conn.execute(
        "INSERT INTO approval_actions (request_id, step_number, approver_email, action, comments, acted_at) VALUES (?,?,?,?,?,?)",
        (request_id, request[1], approver_email, action, comments, datetime.now().isoformat())
    )

    if action == "reject":
        conn.execute(
            "UPDATE approval_requests SET status='rejected', completed_at=? WHERE id=?",
            (datetime.now().isoformat(), request_id)
        )
    elif action == "approve":
        chain = APPROVAL_CHAINS[request[0]]
        next_step = request[1] + 1

        if next_step >= len(chain["steps"]):
            conn.execute(
                "UPDATE approval_requests SET status='approved', completed_at=? WHERE id=?",
                (datetime.now().isoformat(), request_id)
            )
        else:
            conn.execute(
                "UPDATE approval_requests SET current_step=? WHERE id=?",
                (next_step, request_id)
            )
            notify_approver(chain["steps"][next_step]["role"], request_id, "")

    conn.commit()
    return {"status": "processed", "action": action}

Step 5: Handle Timeouts and Escalation

from datetime import timedelta

def check_stalled_requests(conn):
    rows = conn.execute(
        "SELECT id, request_type, current_step, created_at FROM approval_requests WHERE status='pending'"
    ).fetchall()

    for req_id, req_type, step, created in rows:
        chain = APPROVAL_CHAINS[req_type]
        created_dt = datetime.fromisoformat(created)
        hours_waiting = (datetime.now() - created_dt).total_seconds() / 3600

        if hours_waiting > chain["timeout_hours"]:
            print(f"ESCALATING: Request #{req_id} stalled for {int(hours_waiting)}h")
            notify_approver(chain["escalation"], req_id, "ESCALATED - timeout exceeded")

What to Build Next

Add a dashboard showing all pending approvals per person. Most approval bottlenecks come from one or two people who do not check their queue. Visibility fixes that faster than reminders.

Related Reading

Want this system built for your business?

Get a free assessment. We will map every system your business needs and show you the ROI.

Get Your Free Assessment

Related Systems