Sending Cost Alerts to Slack with Webhooks
Chat is where cost awareness lives day to day, and this page shows the exact Slack incoming-webhook calls that turn a database cost signal into a readable, color-coded Block Kit message without tripping Slack’s rate limits.
Back to: Alert Routing & Escalation
A Slack incoming webhook is a per-channel URL that accepts a JSON POST and renders it as a message in one fixed channel. It is the right transport for the informational and warning end of the cost-severity spectrum — a soft-limit approach, a projected month-end overspend, a chargeback summary — where the goal is visibility for a team, not a paging incident for one on-call engineer. The severity that decides whether an alert reaches Slack at all comes from the routing policy in alert routing and escalation, and the higher-severity path to a pager is covered separately in routing quota breach alerts to PagerDuty. This page covers building a Block Kit message, color-coding by severity, throttling to stay under Slack’s limits, and honoring Retry-After on 429.
The webhook contract is intentionally minimal — a 200 with the body ok means delivered, anything else means it was not — so almost all of the engineering here is in shaping a message a human can triage at a glance and in pacing the sends so Slack does not start rejecting them.
Prerequisites
Before sending, set up the webhook and pin down the message shape.
A Slack incoming webhook URL. Create a Slack app, enable Incoming Webhooks, and add one to the target channel. Slack returns a URL of the form
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX. The channel is baked into the URL — a webhook cannot post elsewhere. Treat the URL as a secret: anyone holding it can post to your channel, so store it in your secret manager under the same controls as every other pipeline credential in security and access control for cost data.export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"Python: 3.10 or newer.
Libraries: an async HTTP client. The examples use
httpx;requestsworks for a synchronous variant.pip install "httpx>=0.27"A canonical alert envelope. As with every channel adapter, this one consumes the normalized
CostAlert—severity,summary,cost_center,projected_overspend, and adetailsmap — and only translates it into Slack’s Block Kit shape.
Step-by-Step Implementation
The Slack adapter builds a Block Kit message, wraps it in a color-coded attachment, and posts it through a token-bucket throttle that respects Slack’s roughly one-message-per-second-per-webhook budget, retrying on 429 by honoring the Retry-After header.
Step 1 — Build a Block Kit message
Block Kit renders structured blocks — a header, a section with side-by-side fields, and a context footer — far more legibly than a wall of text. The color bar comes from wrapping the blocks in an attachment with a color, which Slack draws as a vertical stripe.
from decimal import Decimal
# Slack attachment color bar, keyed by canonical severity.
SEVERITY_COLOR = {
"info": "#36a64f", # green
"warning": "#e8a317", # amber
"critical": "#d00000", # red
}
def build_slack_message(alert) -> dict:
"""Render a CostAlert into a color-coded Block Kit attachment payload."""
color = SEVERITY_COLOR.get(alert.severity, "#808080")
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"Cost alert: {alert.cost_center}"},
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Severity:*\n{alert.severity.upper()}"},
{"type": "mrkdwn", "text": f"*Cost center:*\n{alert.cost_center}"},
{"type": "mrkdwn", "text": f"*Summary:*\n{alert.summary}"},
{"type": "mrkdwn",
"text": f"*Projected overspend:*\n${alert.projected_overspend:,.2f}"},
],
},
{
"type": "context",
"elements": [
{"type": "mrkdwn",
"text": f"dedup `{alert.dedup_key}` · fired {alert.fired_at:%Y-%m-%d %H:%M UTC}"},
],
},
]
# attachments[].color draws the severity stripe; blocks carry the content
return {"attachments": [{"color": color, "blocks": blocks}]}
# Expected shape (abridged):
# {"attachments": [{"color": "#e8a317", "blocks": [{"type": "header", ...}, ...]}]}
Step 2 — Throttle sends with a token bucket
Slack rate-limits incoming webhooks to roughly one message per second per webhook, with short bursts tolerated. A token-bucket limiter smooths a burst of per-tenant alerts into a compliant stream without dropping any, which is what keeps you out of 429 territory in the first place.
import asyncio
import time
class TokenBucket:
"""Async token bucket: refill `rate` tokens/sec up to `capacity`."""
def __init__(self, rate: float = 1.0, capacity: int = 3):
self.rate = rate
self.capacity = capacity
self._tokens = float(capacity)
self._updated = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> None:
"""Block until a token is available, then consume one."""
async with self._lock:
while True:
now = time.monotonic()
self._tokens = min(
self.capacity, self._tokens + (now - self._updated) * self.rate
)
self._updated = now
if self._tokens >= 1:
self._tokens -= 1
return
deficit = (1 - self._tokens) / self.rate
await asyncio.sleep(deficit)
Step 3 — Post with Retry-After-aware retry
Even with throttling, a shared webhook or a coordinated burst can still earn a 429. Slack returns a Retry-After header in seconds; honoring it exactly is the difference between recovering and being throttled harder. Everything else — a 200 with body ok — is success.
The flow below shows the throttle gate, a 429 that honors Retry-After, and the successful send.
import logging
import os
import httpx
_bucket = TokenBucket(rate=1.0, capacity=3)
MAX_RETRIES = 4
async def send_to_slack(alert, client: httpx.AsyncClient) -> bool:
"""Deliver one CostAlert to Slack, throttled and 429-aware."""
url = os.environ["SLACK_WEBHOOK_URL"]
payload = build_slack_message(alert)
for attempt in range(MAX_RETRIES + 1):
await _bucket.acquire() # pace under ~1 msg/sec/webhook
resp = await client.post(url, json=payload, timeout=10.0)
if resp.status_code == 200:
return True # body is literally "ok"
if resp.status_code == 429:
# Slack tells us exactly how long to wait; obey it
retry_after = float(resp.headers.get("Retry-After", "1"))
logging.warning("Slack 429; honoring Retry-After=%.0fs", retry_after)
await asyncio.sleep(retry_after)
continue
if resp.status_code >= 500:
await asyncio.sleep(1.5 * (2 ** attempt))
continue
# 400 "invalid_payload" / 404 "channel_not_found" are our bug: do not retry
logging.error("Slack rejected message: %s %s", resp.status_code, resp.text)
return False
logging.error("Slack delivery failed after retries for %s", alert.dedup_key)
return False
Expected output for a burst of five alerts through the throttle, one of which hits a 429:
WARNING:root:Slack 429; honoring Retry-After=2s
INFO:root:delivered 5/5 cost alerts to #db-cost-alerts
Verification
Confirm rendering and pacing before enabling the adapter in production.
Render without sending. Assert the payload shape so a Block Kit typo fails a test rather than a live message:
from types import SimpleNamespace from datetime import datetime, timezone from decimal import Decimal alert = SimpleNamespace( severity="warning", cost_center="team-analytics", summary="team-analytics at 96% of budget", dedup_key="qb-analytics-9f2c", projected_overspend=Decimal("318.40"), fired_at=datetime(2026, 7, 18, 9, 30, tzinfo=timezone.utc), ) msg = build_slack_message(alert) assert msg["attachments"][0]["color"] == "#e8a317" assert msg["attachments"][0]["blocks"][0]["type"] == "header"Validate against Slack’s Block Kit Builder. Paste the
blocksarray into the Block Kit Builder in Slack to catch structural errors before runtime; an invalid block returns400 invalid_blocks, which the adapter treats as a non-retryable failure.Confirm the throttle holds. Fire twenty alerts through a single
httpx.AsyncClientand confirm from timestamps that sends are paced at roughly one per second with a short initial burst — proof the token bucket is doing its job and you are not relying on Slack’s429as your rate limiter.
Gotchas & Edge Cases
- A webhook posts to exactly one channel. The channel is fixed in the URL; you cannot redirect a message by setting a
channelfield (modern webhooks ignore it). To route different cost centers to different channels, hold a map ofcost_center -> webhook_urland select the URL per alert. 429is your rate limiter of last resort, not your first. Relying on Slack to throttle you means dropped or long-delayed alerts under load. Pace proactively with the token bucket and treat any429as a signal your send rate is mis-tuned — the same posture as honoring per-integration limits when routing to PagerDuty.- Coalesce related alerts. Ten cost centers breaching in the same minute should become one digest message with ten
sectionblocks, not ten messages. Coalescing cuts both noise and request volume, and it is the natural pairing with the deduplication described in deduplicating and escalating cost alerts. attachmentsis legacy for color, blocks are current for content. Slack’s color bar only exists onattachments, so the idiomatic pattern is a single attachment carryingcolorplus ablocksarray — you get the stripe without the deprecated attachment fields.- A 3,000-character text limit per section. A
sectiontextormrkdwnfield is capped at 3,000 characters; a verbosecustom_detailsdump will return400. Keep the message a summary and link out to a dashboard for detail. - Slack markdown is
mrkdwn, not standard Markdown. Bold is*single asterisks*, links are<url|label>, and there are no headings insidemrkdwn— use aheaderblock instead. Feeding standard Markdown produces literal asterisks in the channel. - A recovery note is not a resolve. Slack has no incident lifecycle, so “the spend recovered” is just another message posting to the channel, ideally in the thread of the original alert via a stored
ts. Do not expect Slack to close anything the way PagerDuty resolves an incident.
Frequently Asked Questions
Should I use an incoming webhook or the chat.postMessage Web API method?
For fire-and-forget alerts to a fixed channel, an incoming webhook is simpler — no OAuth token, no scopes, one URL. Reach for chat.postMessage only when you need capabilities the webhook lacks: posting to a channel chosen at runtime, threading replies by ts, updating a message in place, or reading the posted message back. Those extra powers cost you a bot token with chat:write scope and tighter rate accounting.
What are the real rate limits on an incoming webhook?
Slack documents incoming webhooks at roughly one message per second per webhook, tolerating short bursts. There is no published hard quota beyond that, and sustained oversending earns 429 responses with a Retry-After header. The token bucket in Step 2 targets that one-per-second budget with a small burst capacity, which keeps normal alerting well within limits.
How do I color-code alerts by severity in Block Kit?
Block Kit itself has no color property; the colored stripe comes from wrapping your blocks in a legacy attachment and setting its color to a hex value or one of good, warning, danger. The build_slack_message function maps the canonical severity onto a hex color so info shows green, warning amber, and critical red.
Why did my message post as raw JSON or literal asterisks?
Two common causes. Posting the payload as form-encoded instead of Content-Type: application/json makes Slack render the raw body — always send JSON. Literal asterisks mean you used standard Markdown in a field typed plain_text; switch the field type to mrkdwn so Slack interprets its *bold* and <url|label> syntax.
Should critical cost alerts go to Slack at all?
Slack is the right home for info and warning, but a truly critical breach needs an acknowledged page, not a channel message that can scroll past unseen. The routing policy sends critical alerts to both Slack (for team visibility) and PagerDuty (for an accountable page); Slack alone should never be the escalation path for spend that is actively running away.
Related
- Routing Quota Breach Alerts to PagerDuty — the paging channel for critical breaches, with the Events API v2 and auto-resolve.
- Deduplicating and Escalating Cost Alerts — how the dedup store and coalescing decide which alerts reach this Slack adapter and how often.
- Delivering Cost Reports to Slack and Email — the scheduled-report sibling to these real-time alerts, sharing the same webhook plumbing.
- Alert Routing & Escalation — the routing layer whose severity policy decides whether an alert lands in Slack.
Back to: Alert Routing & Escalation