Slack API in Go With slack-go/slack: A Tested Example
slack-go/slack v0.29.0 is the Go client most people use for the Slack API. We ran a program that posts, reads back and updates a Block Kit message, a Socket Mode bot that answers a mention, and a loop that hit the rate limit, with the output and error types we got.
On this page
To call the Slack API from Go, use github.com/slack-go/slack. It is a community library, not an official Slack SDK, but it covers the Web API, Block Kit and Socket Mode. We ran everything below on 4 October 2026 with v0.29.0 and Go 1.27.1 against a free-plan test workspace. The bot token had chat:write, channels:history and channels:read, and the Socket Mode part used an app-level token with connections:write.
Set up the module
brew install go
go mod init sglab/demo
go get github.com/slack-go/slack@latest
go get resolved v0.29.0 and pulled in one other module, github.com/gorilla/websocket v1.5.3, which the Socket Mode client uses. Both tokens come from environment variables in the code below, so neither one sits in the source.
Post, read back and update a Block Kit message
package main
import (
"errors"
"fmt"
"os"
"time"
"github.com/slack-go/slack"
)
func main() {
api := slack.New(os.Getenv("SLACK_BOT_TOKEN"))
channel := os.Getenv("CHANNEL_ID")
blocks := []slack.Block{
slack.NewSectionBlock(slack.NewTextBlockObject(slack.MarkdownType, "*Deploy* started by go", false, false), nil, nil),
slack.NewContextBlock("", slack.NewTextBlockObject(slack.MarkdownType, "status: running", false, false)),
}
ch, ts, err := api.PostMessage(channel,
slack.MsgOptionText("Deploy started", false),
slack.MsgOptionBlocks(blocks...))
fmt.Printf("posted channel=%s ts=%s err=%v\n", ch, ts, err)
hist, err := api.GetConversationHistory(&slack.GetConversationHistoryParameters{
ChannelID: channel, Latest: ts, Inclusive: true, Limit: 1})
if err == nil && len(hist.Messages) > 0 {
m := hist.Messages[0]
fmt.Printf("read back: text=%q blocks=%d bot_id=%s\n", m.Text, len(m.Blocks.BlockSet), m.BotID)
}
done := []slack.Block{
slack.NewSectionBlock(slack.NewTextBlockObject(slack.MarkdownType, "*Deploy* finished", false, false), nil, nil),
}
_, _, _, err = api.UpdateMessage(channel, ts, slack.MsgOptionText("Deploy finished", false), slack.MsgOptionBlocks(done...))
fmt.Printf("updated err=%v\n", err)
_, _, err = api.PostMessage("C000BADID00", slack.MsgOptionText("x", false))
fmt.Printf("bad channel: %T %v\n", err, err)
var se slack.SlackErrorResponse
if errors.As(err, &se) {
fmt.Printf(" errors.As SlackErrorResponse: Err=%q Messages=%v\n", se.Err, se.ResponseMetadata.Messages)
}
_, _, err = api.PostMessage(channel, slack.MsgOptionBlocks(slack.NewSectionBlock(nil, nil, nil)))
if errors.As(err, &se) {
fmt.Printf("empty section: Err=%q Messages=%v\n", se.Err, se.ResponseMetadata.Messages)
}
start := time.Now()
n := 0
for i := 0; i < 60; i++ {
_, err = api.GetConversationInfo(&slack.GetConversationInfoInput{ChannelID: channel})
if err != nil {
var rl *slack.RateLimitedError
if errors.As(err, &rl) {
fmt.Printf("rate limited after %d calls: %T RetryAfter=%v\n", n, err, rl.RetryAfter)
} else {
fmt.Printf("error after %d calls: %T %v\n", n, err, err)
}
break
}
n++
}
fmt.Printf("%d conversations.info calls in %.1f s\n", n, time.Since(start).Seconds())
_, _, err = api.DeleteMessage(channel, ts)
fmt.Printf("deleted err=%v\n", err)
}
go run . printed:
posted channel=C0C6GPM7UQ6 ts=1791077362.935179 err=<nil>
read back: text="Deploy started" blocks=2 bot_id=B0C6JHSGEAW
updated err=<nil>
bad channel: slack.SlackErrorResponse channel_not_found
errors.As SlackErrorResponse: Err="channel_not_found" Messages=[]
empty section: Err="invalid_blocks" Messages=[[ERROR] must define either `text` or `fields` [json-pointer:/blocks/0/type]]
60 conversations.info calls in 12.3 s
deleted err=<nil>
Notes from the run:
- •
PostMessagereturns the channel ID and the messagets. Keep thets:UpdateMessage,DeleteMessageand thread replies all need it. - •
UpdateMessagereturns four values in v0.29.0 (channel, ts, text, error). - • A Slack error comes back as the value type
slack.SlackErrorResponse, soerrors.Asneeds a value, not a pointer. ItsErrfield is the Slack code. For Block Kit mistakes,ResponseMetadata.Messagesholds the line that says what is wrong, heremust define either text or fieldsfor a section with neither. - • 60 sequential
conversations.infocalls took 12.3 seconds and none was limited.
Never appear "away" on Slack again
Cloud-based. No downloads. Works 24/7 even when your laptop is off.
Hitting the rate limit
To see the rate-limit type, we sent 600 conversations.info calls from 20 goroutines:
var rl *slack.RateLimitedError
if errors.As(err, &rl) {
atomic.AddInt64(&limited, 1)
first.Do(func() {
fmt.Printf("first 429 after %.1f s and %d ok calls: %T RetryAfter=%v Retryable=%v msg=%q\n",
time.Since(start).Seconds(), atomic.LoadInt64(&ok), err, rl.RetryAfter, rl.Retryable(), rl.Error())
})
continue
}
first 429 after 3.1 s and 203 ok calls: *slack.RateLimitedError RetryAfter=10s Retryable=true msg="slack rate limit exceeded, retry after 10s"
600 calls from 20 goroutines in 8.6 s: 260 ok, 340 rate limited
This one is a pointer type, *slack.RateLimitedError, unlike SlackErrorResponse. RetryAfter is a time.Duration read from the Retry-After header, 10 seconds here. The library does not wait and retry for you, so a loop that ignores the error keeps getting 429s: 340 of our 600 calls did. Sleep for rl.RetryAfter and try again. How the limits are counted per method is on our Slack API rate limits page.
A Socket Mode bot that answers a mention
Socket Mode lets the bot receive events over a WebSocket, with no public URL. The app needs Socket Mode turned on, the app_mention bot event and the app_mentions:read scope:
package main
import (
"fmt"
"os"
"time"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
)
func main() {
api := slack.New(os.Getenv("SLACK_BOT_TOKEN"), slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")))
client := socketmode.New(api)
start := time.Now()
go func() {
for evt := range client.Events {
fmt.Printf("%6.2fs %s\n", time.Since(start).Seconds(), evt.Type)
if evt.Type != socketmode.EventTypeEventsAPI {
continue
}
ev := evt.Data.(slackevents.EventsAPIEvent)
client.Ack(*evt.Request)
if m, ok := ev.InnerEvent.Data.(*slackevents.AppMentionEvent); ok {
fmt.Printf(" app_mention from %s: %q\n", m.User, m.Text)
_, ts, err := api.PostMessage(m.Channel,
slack.MsgOptionText("pong from slack-go", false),
slack.MsgOptionTS(m.TimeStamp))
fmt.Printf(" replied in thread ts=%s err=%v\n", ts, err)
os.Exit(0)
}
}
}()
if err := client.Run(); err != nil {
fmt.Println("run error:", err)
}
}
We started it and then mentioned the bot in the channel:
0.00s connecting
0.85s connected
0.86s hello
9.80s events_api
10.00s events_api
app_mention from U0B7L4YK420: "<@U0C6NCVVBNG> ping from the go test"
replied in thread ts=1791077416.293109 err=<nil>
One mention produced two events_api envelopes 0.2 seconds apart, because the app was also subscribed to message.channels. The first was the plain message event and the second the app_mention. Call client.Ack for every envelope, including the ones you do not handle. How Slack retries events an app did not answer is measured on our Events API retries page.
Only one Socket Mode connection should run per app while you test. With two running, Slack splits the events between them, so each process sees only some of them. We stopped our other listener before starting this one.
Stop Jiggling Your Mouse.
Join hundreds of remote workers who never worry about their Slack status. Set it up once, stay green forever.
Related Articles
@slack/web-api v8: A Tested Node.js and TypeScript Example
@slack/web-api 8.2.0 runs on Node 20 or newer and sends requests with fetch. We posted, paginated, caught a Slack error and a rate limit in TypeScript, and found that the v7 agent option for proxies is silently ignored in v8.
slack-ruby-client: A Tested Ruby Example, Errors and Limits
slack-ruby-client 3.2.0 is the Ruby gem for the Slack Web API. We installed it, posted, read back, reacted and paginated against a real workspace, and recorded the error classes it raised, including the rate-limit error that a rescue of SlackError does not catch.
Slack Status Change Notification: The Events an App Gets, Timed
Slack does not tell anyone when you change your status. An app can find out within a second through the user_status_changed and user_change events. We changed a status 7 ways and timed each event, including statuses that expired.