Slack Bot Send messages and rich content to Slack channels using Incoming Webhooks or the Slack Web API. Build formatted announcements, marketing reports, metrics dashboards, and community updates with Block Kit. Prerequisites Requires either SLACK_WEBHOOK_URL or SLACK_BOT_TOKEN set in .env , .env.local , or ~/.claude/.env.global . source ~/.claude/.env.global 2
/dev/null source .env 2
/dev/null source .env.local 2
/dev/null if [ -n " $SLACK_WEBHOOK_URL " ] ; then echo "SLACK_WEBHOOK_URL is set. Webhook mode available." elif [ -n " $SLACK_BOT_TOKEN " ] ; then echo "SLACK_BOT_TOKEN is set. Web API mode available." else echo "Neither SLACK_WEBHOOK_URL nor SLACK_BOT_TOKEN is set." echo "See the Setup Guide below to configure Slack credentials." fi If neither variable is set, instruct the user to follow the Setup Guide section below. Setup Guide Option A: Incoming Webhook (Simple) Incoming Webhooks are the fastest way to post messages. They require no OAuth scopes and are scoped to a single channel. Go to https://api.slack.com/apps and click Create New App
From scratch . Name the app (e.g., "Marketing Bot") and select your workspace. In the left sidebar, click Incoming Webhooks and toggle it On . Click Add New Webhook to Workspace at the bottom. Select the channel to post to and click Allow . Copy the Webhook URL (starts with https://hooks.slack.com/services/... ). Add it to your environment: echo 'SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxxx'
.env Limitations: One webhook per channel. Cannot read messages, list channels, or reply to threads programmatically (you must know the thread_ts from a prior API response). Option B: Bot Token (Full Featured) Bot tokens give access to the full Slack Web API: post to any channel the bot is in, reply to threads, list channels, upload files, and more. Go to https://api.slack.com/apps and click Create New App
From scratch . Name the app and select your workspace. In the left sidebar, click OAuth & Permissions . Under Bot Token Scopes , add these scopes: chat:write - Post messages chat:write.public - Post to channels without joining channels:read - List public channels files:write - Upload files (optional, for images/reports) reactions:write - Add emoji reactions (optional) Click Install to Workspace at the top and authorize. Copy the Bot User OAuth Token (starts with xoxb- ). Add it to your environment: echo 'SLACK_BOT_TOKEN=xoxb-your-token-here'
.env Invite the bot to the channels it should post in: type /invite @YourBotName in each channel. Optional: Set a default channel for convenience: echo 'SLACK_DEFAULT_CHANNEL=#marketing'
.env Method 1: Incoming Webhooks Send a Simple Text Message curl -s -X POST " $SLACK_WEBHOOK_URL " \ -H "Content-Type: application/json" \ -d '{ "text": "Hello from the marketing bot!" }' Send a Message with Username and Icon Override curl -s -X POST " $SLACK_WEBHOOK_URL " \ -H "Content-Type: application/json" \ -d '{ "text": "New blog post published!", "username": "Marketing Bot", "icon_emoji": ":mega:" }' Send a Message with Block Kit (Webhook) curl -s -X POST " $SLACK_WEBHOOK_URL " \ -H "Content-Type: application/json" \ -d '{ "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New Product Launch" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "Product X is now live! Check out the announcement." } }, { "type": "divider" }, { "type": "section", "text": { "type": "mrkdwn", "text": "Read the full announcement on our blog." }, "accessory": { "type": "button", "text": { "type": "plain_text", "text": "Read More" }, "url": "https://example.com/blog/launch" } } ] }' Method 2: Slack Web API (Bot Token) The Web API provides full control over message delivery, threading, channel management, and more. API Base All requests go to https://slack.com/api/ with the header Authorization: Bearer {SLACK_BOT_TOKEN} . Post a Message to a Channel curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d '{ "channel": "#marketing", "text": "Weekly metrics report is ready!", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Weekly Marketing Metrics" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "Here are the numbers for this week." } } ] }' The response includes a ts (timestamp) field which identifies the message. Save this value for threading replies:
Post and capture the message timestamp for threading
RESPONSE
$( curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d '{ "channel": "#marketing", "text": "Thread parent message" }' ) MESSAGE_TS = $( echo " $RESPONSE " | python3 -c "import json,sys ; print ( json.load ( sys.stdin ) .get ( 'ts' , '' ) ) " ) echo "Message timestamp: $MESSAGE_TS " Reply to a Thread Use the thread_ts parameter to reply inside an existing thread: curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d "{ \" channel \" : \"
marketing
\" , \" thread_ts \" : \" ${MESSAGE_TS} \" , \" text \" : \" This is a threaded reply with additional details. \" }" To also broadcast the reply to the channel (so it appears in the main conversation as well), add "reply_broadcast": true . Update an Existing Message curl -s -X POST "https://slack.com/api/chat.update" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d "{ \" channel \" : \"
marketing
- \"
- ,
- \"
- ts
- \"
- :
- \"
- ${MESSAGE_TS}
- \"
- ,
- \"
- text
- \"
- :
- \"
- Updated message content.
- \"
- ,
- \"
- blocks
- \"
- [] }" Delete a Message curl -s -X POST "https://slack.com/api/chat.delete" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d "{ \" channel \" : \"
marketing
\" , \" ts \" : \" ${MESSAGE_TS} \" }" List Public Channels Useful for discovering which channel to post to: curl -s "https://slack.com/api/conversations.list?types=public_channel&limit=100" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " | \ python3 -c " import json, sys data = json.load(sys.stdin) for ch in data.get('channels', []): members = ch.get('num_members', 0) print(f \"
{ch['name']} | Members: {members} | ID: {ch['id']}
\"
)
"
Upload a File to a Channel
curl
-s
-X
POST
"https://slack.com/api/files.uploadV2"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
\
-F
"file=@report.pdf"
\
-F
"filename=weekly-report.pdf"
\
-F
"channel_id=C0123456789"
\
-F
"initial_comment=Here is this week's marketing report."
Add an Emoji Reaction
curl
-s
-X
POST
"https://slack.com/api/reactions.add"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
\
-H
"Content-Type: application/json"
\
-d
"{
\"
channel
\"
:
\"
C0123456789
\"
,
\"
timestamp
\"
:
\"
${MESSAGE_TS}
\"
,
\"
name
\"
:
\"
white_check_mark
\"
}"
Slack Block Kit Reference
Block Kit is Slack's UI framework for building rich, interactive messages. Messages are composed
of an array of blocks, each with a specific type and structure.
Block Types
Block Type
Purpose
Supports mrkdwn
header
Large bold title text
No (plain_text only)
section
Primary content block with text and optional accessory
Yes
divider
Horizontal line separator
N/A
image
Full-width image with alt text
N/A
context
Small, muted text and images (for metadata, timestamps)
Yes
actions
Row of interactive elements (buttons, selects, date pickers)
N/A
rich_text
Advanced formatted text (lists, quotes, code blocks)
N/A
Header Block
{
"type"
:
"header"
,
"text"
:
{
"type"
:
"plain_text"
,
"text"
:
"Weekly Marketing Report"
,
"emoji"
:
true
}
}
Section Block
Plain text section:
{
"type"
:
"section"
,
"text"
:
{
"type"
:
"mrkdwn"
,
"text"
:
"Traffic is up 23% this week compared to last week.\nOrganic search drove most of the growth."
}
}
Section with fields (two-column layout):
{
"type"
:
"section"
,
"fields"
:
[
{
"type"
:
"mrkdwn"
,
"text"
:
"Visitors\n12,450"
}
,
{
"type"
:
"mrkdwn"
,
"text"
:
"Signups\n342"
}
,
{
"type"
:
"mrkdwn"
,
"text"
:
"MRR\n$28,500"
}
,
{
"type"
:
"mrkdwn"
,
"text"
:
"Churn\n1.2%"
}
]
}
Section with a button accessory:
{
"type"
:
"section"
,
"text"
:
{
"type"
:
"mrkdwn"
,
"text"
:
"New blog post: How We Grew 10x in 6 Months"
}
,
"accessory"
:
{
"type"
:
"button"
,
"text"
:
{
"type"
:
"plain_text"
,
"text"
:
"Read Post"
}
,
"url"
:
"https://example.com/blog/growth-story"
,
"action_id"
:
"read_blog_post"
}
}
Section with an image accessory:
{
"type"
:
"section"
,
"text"
:
{
"type"
:
"mrkdwn"
,
"text"
:
"New Feature: Dark Mode\nOur most requested feature is finally here."
}
,
"accessory"
:
{
"type"
:
"image"
,
"image_url"
:
"https://example.com/images/dark-mode-preview.png"
,
"alt_text"
:
"Dark mode preview"
}
}
Divider Block
{
"type"
:
"divider"
}
Image Block
{
"type"
:
"image"
,
"image_url"
:
"https://example.com/images/chart.png"
,
"alt_text"
:
"Weekly traffic chart"
,
"title"
:
{
"type"
:
"plain_text"
,
"text"
:
"Traffic Overview"
}
}
Context Block
{
"type"
:
"context"
,
"elements"
:
[
{
"type"
:
"mrkdwn"
,
"text"
:
"Posted by Marketing Team | Feb 10, 2026"
}
,
{
"type"
:
"image"
,
"image_url"
:
"https://example.com/logo-small.png"
,
"alt_text"
:
"Company logo"
}
]
}
Actions Block (Buttons)
{
"type"
:
"actions"
,
"elements"
:
[
{
"type"
:
"button"
,
"text"
:
{
"type"
:
"plain_text"
,
"text"
:
"Approve"
}
,
"style"
:
"primary"
,
"action_id"
:
"approve_action"
,
"value"
:
"approved"
}
,
{
"type"
:
"button"
,
"text"
:
{
"type"
:
"plain_text"
,
"text"
:
"Reject"
}
,
"style"
:
"danger"
,
"action_id"
:
"reject_action"
,
"value"
:
"rejected"
}
,
{
"type"
:
"button"
,
"text"
:
{
"type"
:
"plain_text"
,
"text"
:
"View Details"
}
,
"url"
:
"https://example.com/details"
,
"action_id"
:
"view_details"
}
]
}
Button styles:
"primary"
(green),
"danger"
(red), or omit for default (gray).
Note on interactivity:
Buttons with
action_id
(no
url
) require a Request URL configured
in your Slack App settings under
Interactivity & Shortcuts
to receive the button click
payload. Buttons with a
url
field open the link directly and do not require a backend.
Block Kit Limits
Limit
Value
Blocks per message
50
Characters per text block
3,000
Characters per header
150
Fields per section
10
Elements per actions block
25
Elements per context block
10
Block Kit Builder
Use the visual builder to design and preview messages before coding them:
https://app.slack.com/block-kit-builder
Slack mrkdwn Formatting Guide
Slack uses its own markdown variant called mrkdwn. It differs from standard Markdown in
several ways.
Formatting
Syntax
Example
Bold
text
bold text
Italic
text
italic text
Strikethrough
~text~
strikethrough
Code (inline)
text
inline code
Code block
text
Multi-line code block
Blockquote
text Quoted text Link https://url|display text Clickable link User mention <@U0123456> @username Channel mention <#C0123456>
channel
Emoji
:emoji_name:
:rocket:
Bulleted list
Start line with
-
or
*
Bullet point
Numbered list
Start line with
1.
Numbered item
Line break
\n
in JSON string
New line
Important differences from standard Markdown:
Bold uses single asterisks
bold
, not double
bold
.
Italic uses underscores
italic
, not single asterisks.
Links use
Write the payload
cat
/tmp/slack-message.json << 'PAYLOAD' { "channel": "#marketing", "text": "Fallback text for notifications", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Message Title" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "Message body with bold and italic formatting." } } ] } PAYLOAD
Send it
curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d @/tmp/slack-message.json Scheduled Messages Post a message at a specific future time using chat.scheduleMessage :
Schedule a message for a specific Unix timestamp
Use: date -d "2026-02-12 09:00:00" +%s (Linux) or date -j -f "%Y-%m-%d %H:%M:%S" "2026-02-12 09:00:00" +%s (macOS)
SEND_AT
$( date -j -f "%Y-%m-%d %H:%M:%S" "2026-02-12 09:00:00" +%s 2
/dev/null || date -d "2026-02-12 09:00:00" +%s ) curl -s -X POST "https://slack.com/api/chat.scheduleMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d "{ \" channel \" : \"
marketing
- \"
- ,
- \"
- post_at
- \"
- :
- ${SEND_AT}
- ,
- \"
- text
- \"
- :
- \"
- Good morning team! Here is today's marketing agenda.
- \"
- ,
- \"
- blocks
- \"
- []
}"
List scheduled messages:
curl
-s
"https://slack.com/api/chat.scheduledMessages.list"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
|
\
python3
-c
"
import json, sys, datetime
data = json.load(sys.stdin)
for msg in data.get('scheduled_messages', []):
ts = datetime.datetime.fromtimestamp(msg['post_at']).strftime('%Y-%m-%d %H:%M')
print(f
\"
ID: {msg['id']} | Channel: {msg['channel_id']} | Scheduled: {ts}
\"
)
"
Delete a scheduled message:
curl
-s
-X
POST
"https://slack.com/api/chat.deleteScheduledMessage"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
\
-H
"Content-Type: application/json"
\
-d
'{
"channel": "C0123456789",
"scheduled_message_id": "Q0123456789"
}'
Multi-Channel Posting
Post the same message to multiple channels:
CHANNELS
=
(
"#marketing"
"#general"
"#product"
)
MESSAGE
=
'{"text":"Big announcement coming tomorrow!","blocks":[{"type":"section","text":{"type":"mrkdwn","text":":mega: Big announcement coming tomorrow! Stay tuned."}}]}'
for
CHANNEL
in
"
${CHANNELS
[
@
]
}
"
;
do
echo
"Posting to
${CHANNEL}
..."
echo
"
$MESSAGE
"
|
python3
-c
"
import json, sys
msg = json.load(sys.stdin)
msg['channel'] = '
${CHANNEL}
'
print(json.dumps(msg))
"
|
curl
-s
-X
POST
"https://slack.com/api/chat.postMessage"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
\
-H
"Content-Type: application/json"
\
-d
@-
|
python3
-c
"
import json, sys
r = json.load(sys.stdin)
if r.get('ok'):
print(f' Sent. ts={r[
\"
ts
\"
]}')
else:
print(f' Error: {r.get(
\"
error
\"
,
\"
unknown
\"
)}')
"
done
Error Handling
Common API Errors
Error
Cause
Fix
invalid_auth
Bad or expired token
Regenerate the bot token in Slack App settings
channel_not_found
Bot not in channel or wrong channel name
Invite bot with
/invite @BotName
or use channel ID
not_in_channel
Bot needs to join the channel first
Invite the bot or use
chat:write.public
scope
too_many_attachments
Over 50 blocks
Split the message into multiple posts or thread replies
msg_too_long
Text exceeds 40,000 characters
Shorten the message or split into parts
rate_limited
Too many requests
Wait the number of seconds in the
Retry-After
header
missing_scope
Token lacks required permission
Add the scope in OAuth & Permissions and reinstall the app
Validate a Response
RESPONSE
=
$(
curl
-s
-X
POST
"https://slack.com/api/chat.postMessage"
\
-H
"Authorization: Bearer
${SLACK_BOT_TOKEN}
"
\
-H
"Content-Type: application/json"
\
-d
'{"channel":"#marketing","text":"Test message"}'
)
python3
-c
"
import json, sys
r = json.loads('
${RESPONSE}
'.replace(
\"
'
\"
,
\"
\"
))
if r.get('ok'):
print(f'Message sent successfully. ts={r[
\"
ts
\"
]} channel={r[
\"
channel
\"
]}')
else:
print(f'Error: {r.get(
\"
error
\"
,
\"
unknown
\"
)}')
if r.get('response_metadata', {}).get('messages'):
for m in r['response_metadata']['messages']:
print(f' Detail: {m}')
"
2
/dev/null || echo " $RESPONSE " A more robust approach using a temp file: RESPONSE_FILE = $( mktemp ) curl -s -X POST "https://slack.com/api/chat.postMessage" \ -H "Authorization: Bearer ${SLACK_BOT_TOKEN} " \ -H "Content-Type: application/json" \ -d '{"channel":"#marketing","text":"Test message"}' \ -o " $RESPONSE_FILE " python3 -c " import json with open(' ${RESPONSE_FILE} ') as f: r = json.load(f) if r.get('ok'): print(f'Sent. ts={r[ \" ts \" ]}') else: print(f'Error: {r.get( \" error \" )}') " rm -f " $RESPONSE_FILE " Tips Always include a text field alongside blocks — it serves as the fallback for notifications, accessibility readers, and clients that do not support Block Kit. Use the Block Kit Builder at https://app.slack.com/block-kit-builder to visually design and preview messages before building the curl commands. For production workflows, use chat.postMessage (Web API) over webhooks. It returns a message ts you can use for threading, updating, and deleting. Thread long reports. Post a summary as the parent message and details as threaded replies to keep channels clean. Use :emoji: codes in plain_text fields with "emoji": true to render emoji in headers and button labels. Escape special characters in mrkdwn: & becomes & , < becomes < ,
becomes > . Rate limits: Slack allows roughly 1 message per second per channel. For bulk posting, add a 1-second delay between requests. When posting metrics, use section fields for the two-column layout rather than trying to format tables in mrkdwn (Slack does not support tables). Always show the user the full message payload and ask for confirmation before posting.