The quickest way to get Airflow failures into Slack is an incoming webhook. Slack gives you a URL that posts to one channel, Airflow stores it as a connection, and a failure callback on every task sends the message.

Copy and paste

1. Install the Slack provider, with the same constraints file you used for Airflow:

pip install apache-airflow-providers-slack

2. Create the webhook. In Slack, create an app, turn on Incoming Webhooks, add a webhook to the alerts channel and copy its URL.

3. Store it as a connection:

export AIRFLOW_CONN_SLACK_ALERTS='{"conn_type": "slackwebhook", "password": "https://hooks.slack.com/services/T000/B000/XXXX"}'

In the UI the same connection is under Admin > Connections: type Slack Incoming Webhook, the URL in the Webhook Token field.

4. Attach the callback to every task:

import pendulum
from airflow.sdk import dag, task
from airflow.providers.slack.notifications.slack_webhook import send_slack_webhook_notification

slack_task_failed = send_slack_webhook_notification(
    slack_webhook_conn_id="slack_alerts",
    text=(
        ":red_circle: Task failed: *{{ dag.dag_id }}.{{ ti.task_id }}*\n"
        "Run: {{ run_id }} | Try: {{ ti.try_number }}\n"
        "Error: {{ exception }}\n"
        "<{{ ti.log_url }}|Open the log>"
    ),
)

@dag(
    schedule="0 6 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    default_args={"on_failure_callback": slack_task_failed},
)
def sales_daily():
    @task
    def load_sales():
        raise ValueError("table sales_raw not found")

    load_sales()

sales_daily()

The message Slack receives:

:red_circle: Task failed: *sales_daily.load_sales*
Run: scheduled__2026-05-15T06:00:00+00:00 | Try: 1
Error: table sales_raw not found
<http://localhost:8080/dags/sales_daily/runs/scheduled__2026-05-15T06%3A00%3A00%2B00%3A00/tasks/load_sales?try_number=1|Open the log>

One message per task or one per run

In default_args, the callback fires for every task that fails after its last retry. A DAG with ten tasks that all depend on a failed one still sends one message, because the others end as upstream_failed, not as failed.

To get a single message per failed run instead, pass the callback to the DAG itself. Its context has no failed task, but it has the reason:

slack_run_failed = send_slack_webhook_notification(
    slack_webhook_conn_id="slack_alerts",
    text=":rotating_light: DAG run failed: *{{ dag.dag_id }}* ({{ run_id }}, {{ reason }})",
)

@dag(..., on_failure_callback=slack_run_failed)

For the example above reason is task_failure.

Do not use ds in the message

The text is a template, rendered when the task fails. If it uses {{ ds }} and the run has no logical date, as with airflow dags trigger my_dag run without --logical-date, the template fails with 'ds' is undefined and no message is sent at all. Use {{ run_id }} or {{ dag_run.run_after }}, which every run has.

The link to the log

ti.log_url is built from the API server address, http://localhost:8080 by default. Set the address people use to reach Airflow, so that the link in Slack works:

export AIRFLOW__API__BASE_URL=https://airflow.example.com

With a bot token instead of a webhook

A webhook posts to one channel. To choose the channel per message, use a Slack app token (xoxb-...) in a connection of type Slack API and send_slack_notification:

from airflow.providers.slack.notifications.slack import send_slack_notification

slack_task_failed = send_slack_notification(
    slack_conn_id="slack_api",
    channel="#data-alerts",
    text=":red_circle: {{ dag.dag_id }}.{{ ti.task_id }} failed",
)

Testing

airflow dags test sales_daily runs the task-level callback, so you can check the message before deploying. The DAG-level callback failed inside airflow dags test on 3.3.2 but worked with a running scheduler, so test that one with airflow standalone.

Tested with Airflow 3.3.2 and Slack provider 9.10, against a local stand-in for the webhook.

References