go / slack

I post messages to Slack from background jobs. Three methods of the Web API cover it: chat.postMessage, chat.update, and chat.delete. A small client wraps them. The client sends a bot token as Authorization: Bearer ... on every request.

Blocks as maps

Slack's Block Kit is a large, evolving JSON schema. A block is a map[string]any with an alias for readability, rather than a typed Go struct per element:

type Block = map[string]any

slack.Block{
	"type": "section",
	"text": map[string]any{"type": "mrkdwn", "text": "hello"},
}

Callers stay close to the JSON in Slack's Block Kit Builder. The cost is no compile-time schema check. That is the right trade for a format that Slack changes on its own schedule.

Methods

Each method builds a body map and delegates to a shared do. Post returns the message timestamp (ts), the handle needed to later update or delete it. The text field is the device-notification fallback; blocks render the body:

func (c *Client) Post(ctx context.Context, channel, text string, blocks []Block) (string, error) {
	body := map[string]any{
		"channel":      channel,
		"text":         text,
		"link_names":   true,
		"blocks":       blocks,
		"unfurl_media": false,
	}
	resp, err := c.do(ctx, c.postURL, body, singleAttempt)
	if err != nil {
		return "", err
	}
	return resp.TS, nil
}

Retry policy per method

Retry policy differs by method, not by client. Neither Post nor Update is idempotent, and a retried post risks a duplicate message. So both run once (singleAttempt = []time.Duration{0}).

Delete retries on Slack's default transient schedule, because it runs as a best-effort cleanup job and chat.delete is idempotent. A retry of an applied delete returns message_not_found:

func (c *Client) Delete(ctx context.Context, channel, ts string) error {
	body := map[string]any{"channel": channel, "ts": ts}
	_, err := c.do(ctx, c.deleteURL, body, httputil.DefaultRetryDelays)
	return err
}

The retry schedule is a plain argument to do, so the policy lives at the call site where the idempotency reasoning does.

The 200 {"ok": false} gotcha

Slack returns HTTP 200 for application-level failures (channel_not_found, missing_scope, rate limits) with an {"ok": false, "error": "..."} body. A client that only checks the status code treats these as success. So do checks both the status and the ok field:

func (c *Client) do(ctx context.Context, url string, body map[string]any, retryDelays []time.Duration) (apiResponse, error) {
	buf, err := json.Marshal(body)
	if err != nil {
		return apiResponse{}, fmt.Errorf("marshal request: %w", err)
	}

	res, err := httputil.Do(ctx, func() (*http.Request, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
		if err != nil {
			return nil, fmt.Errorf("build request: %w", err)
		}
		req.Header.Set("Authorization", "Bearer "+c.token)
		req.Header.Set("Content-Type", "application/json; charset=utf-8")
		return req, nil
	}, httputil.Config{
		Client:      c.httpClient,
		RetryDelays: retryDelays,
	})
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			return apiResponse{}, fmt.Errorf("slack: timeout")
		}
		return apiResponse{}, fmt.Errorf("slack: %w", err)
	}
	if res.StatusCode/100 != 2 {
		return apiResponse{}, fmt.Errorf("slack: HTTP %d", res.StatusCode)
	}
	var parsed apiResponse
	if err := json.Unmarshal(res.Body, &parsed); err != nil {
		return apiResponse{}, fmt.Errorf("slack: parse response: %s", string(res.Body))
	}
	if !parsed.OK {
		if parsed.Error != "" {
			return apiResponse{}, fmt.Errorf("slack: %s", parsed.Error)
		}
		return apiResponse{}, fmt.Errorf("slack: not ok")
	}
	return parsed, nil
}

The client parses only the fields callers act on:

type apiResponse struct {
	OK    bool   `json:"ok"`
	TS    string `json:"ts"`
	Error string `json:"error"`
}

The error string is Slack's error code, which is the actionable part. On a parse failure, do returns the raw body, so an operator can see what Slack sent. A per-request timeout arrives wrapped in a *url.Error. do unwraps it to a stable "slack: timeout", which saves callers from reading transport-layer errors.

Narrowed interfaces

Consumers depend on the smallest surface they use. A worker that only posts takes a one-method interface. *Client satisfies that interface, and tests inject a fake:

type Poster interface {
	Post(ctx context.Context, channel, text string, blocks []Block) (string, error)
}

← All articles