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. Auth is a bot token sent as Authorization: Bearer ... on every request.

Blocks as maps

Slack's Block Kit is a large, evolving JSON schema. Rather than mirror every element as a typed Go struct, a block is a map[string]any aliased for readability:

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, which is the right trade for a format that changes on Slack's schedule, not mine.

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

The interesting decision is that retry policy differs by method, not by client. Post and Update run once (singleAttempt = []time.Duration{0}): a retried post risks a duplicate message, and neither is idempotent.

Delete retries on Slack's default transient schedule, because it runs as a best-effort cleanup job and chat.delete is idempotent. Retrying an already-applied delete just 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
}

Only the fields callers act on are parsed:

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 the raw body is returned so an operator can see what Slack actually sent. A per-request timeout arrives wrapped in a *url.Error; unwrapping it to a stable "slack: timeout" saves callers from introspecting 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 it, and tests inject a fake:

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

← All articles