インフラ

Slack Webhookの実装手順|URL発行からBlock Kit整形と429対策まで

Slack Webhookの実装手順|URL発行からBlock Kit整形と429対策まで

Slackへの通知を作るとき、コードより先に決まってしまうものがあります。送信先チャンネルです。Incoming Webhookで払い出されるURLはインストール時に選んだ1つのチャンネルに結び付いており、公式ドキュメントは payload で送信先や表示名を実行時に上書きすることを明確に否定しています。この記事では、URLを発行する操作からcurlでの最小送信、Block Kitとmrkdwnの記法、1秒1メッセージという投稿レートの制限、chat.postMessage へ移るべき条件までを、公式ドキュメントの記述とそのまま動くコードで整理しました。古い記事のサンプルが通らない理由も、仕様の変更点として扱います。

まとめ:Slack Webhook実装で最初に決める送信方式と上限

Incoming Webhookは https://hooks.slack.com/services/T00000000/B00000000/XXXX の形で1本ずつ払い出され、公式ドキュメントでは「specific to a single user and a single channel」と説明されています。1本のURLは1チャンネル専用だと考えてください。

実装前に決めるべきことは2つ。1つは送信先を動的に変える要件の有無です。公式ドキュメントは「You cannot override the default channel (chosen by the user who installed your app), username, or icon when you’re using incoming webhooks」と明記しており、payload に channel を入れても無視されます。送り分けたいチャンネルの数だけURLを発行するのが現行の解です。

もう1つは投稿の頻度。Slackは投稿系に「apps may post no more than one message per second per channel」という上限を置いており、超えると HTTP 429 と Retry-After ヘッダが返ります。毎秒数十件のイベントを個別通知する設計は、この時点で破綻します。投稿後にメッセージを書き換えたい、スレッドにぶら下げたいという要件があるならWebhookは選びません。応答は本文 ok の文字列だけで、メッセージを特定する ts が返らないためです。

Slack Webhookの送信方向と受信URLへ送るJSONの基本構造

Slackで「Webhook」と呼ばれるものは向きが2種類あり、混同すると設定画面を探す場所から間違えます。仕組みそのものの説明はIncoming Webhookとは?仕組みとSlack・Discordでの使い方で整理しているので、ここではSlackアプリを前提にした実装側の構造だけを扱います。

通信の向きで分かれるIncoming型とOutgoing型の現行の扱い

Incoming Webhookは、外部のシステムがSlackへHTTP POSTを投げてメッセージを投稿させる仕組みです。監視サーバーやCIからの通知はすべてこちらに当たります。逆にOutgoing Webhookは、Slack側のトリガーを外部のURLへ飛ばすもので、レガシーのカスタムインテグレーションに属します。公式ドキュメントは「Though we recommend that all legacy custom integrations should migrate to Slack apps」と書いており、Slack側のイベントを新規に受けたいならイベント購読の仕組みを選ぶのが筋です。

Content-typeヘッダをJSONで明示する理由と応答に返るok

払い出されるURLは https://hooks.slack.com/services/ に続いてチーム識別子(T始まり)、Webhook識別子(B始まり)、トークン相当の文字列という3階層です。3つ目が実質のシークレットで、URL全体が認証情報を兼ねています。

送信時は公式のIncoming Webhooksガイドが示すとおり、ボディにJSONを置き Content-type: application/json を明示します。ヘッダを省くとフォーム形式として送られ、パースに失敗する場合がありました。成功時は HTTP 200 とプレーンテキストの ok、失敗時はエラー文字列が返ります。応答本文にJSONが返ってこない点が、Web APIメソッドとの大きな違いです。

Incoming Webhook URLを発行してcurlで最初の通知を送る手順

発行はSlackアプリの管理画面で完結します。インストール時に管理者の承認依頼が飛ぶ設定のワークスペースも多いので、先に権限を確認しておくと手戻りがありません。

Slackアプリ作成からWebhook URL発行までの画面操作の順序

公式ガイドの手順は4段階で、順番が固定されています。手順3で選んだチャンネルがそのURLの固定の送信先になり、別のチャンネルへ送りたくなったら同じ画面で2本目を追加します。アプリ自体を作り直す必要はありません。

  1. Slack APIのアプリ管理画面で「Create an app」からアプリ名とインストール先ワークスペースを指定する
  2. 左メニューの「Incoming Webhooks」を開き、「Activate Incoming Webhooks」をオンにする
  3. 画面下部の「Add New Webhook to Workspace」を押し、投稿先チャンネルを選んで「Authorize」する
  4. 一覧に追加されたWebhook URLをコピーし、環境変数などのシークレット領域へ保存する

curlでtextだけのJSONを送る最小実装と200・okの確認

疎通確認は1コマンドで終わります。URLを環境変数に入れ、テキストだけのJSONを投げてください。-i を付けてステータス行まで見ると、後述のエラー切り分けが使えます。

export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"

curl -i -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-type: application/json" \
  --data '{"text": "デプロイが完了しました"}'

期待する応答は 200 OK のステータス行と本文 ok の2つ。ここで200が返れば、URLもチャンネルも生きています。この段階で失敗するなら、アプリケーション側のコードを疑う前にURLそのものを疑ってください。シェルの履歴にURLが残る点には注意が要るので、共用サーバーで試すときは --data @payload.json の形でファイル経由にします。

失敗時に返るinvalid_payloadなどエラー文字列の切り分け表

公式ドキュメントは失敗時のエラー文字列を列挙しています。応答本文がそのまま原因を指すので、ログにはステータスコードと本文の両方を残してください。

エラー文字列 主な原因 最初に見る場所
invalid_payload JSONが壊れている 送信ボディとヘッダ
channel_not_found 投稿先が削除・改名された チャンネル一覧
channel_is_archived 投稿先がアーカイブ済み チャンネルの状態
no_service URLが失効している Webhookの一覧画面
no_active_hooks Webhookが削除された アプリの設定画面
action_prohibited 投稿が禁止されている 管理者のポリシー
no_text 本文の中身が空 ペイロード生成処理

公式の一覧にはこのほか posting_to_general_channel_denied(全体チャンネルへの投稿制限)や too_many_attachments も含まれます。頻度が高いのは invalid_payloadchannel_not_found の2つ。後者はSlack側の運用変更が原因で、コードは正しいのに通知だけ止まります。チャンネルの改名でURLが死ぬ挙動は見落としやすいので、通知が届かないと言われたら、まずcurlで1通打って応答本文を読んでください。

Block Kitとmrkdwnで通知を整形する記法とエスケープ規則

テキスト1行の通知で足りるのは最初だけです。障害通知にステータスや担当者を載せ始めた時点で、整形の仕様を避けて通れなくなります。

textとblocksを併記して通知プレビューを壊さない書き方

Block Kitを使うと、見出し・本文・区切り線・ボタンといった部品を配列で組み立てられます。注意したいのは blocks だけを送った場合の挙動で、デスクトップ通知やチャンネル一覧のプレビューには本文が出ません。text を要約として併記しておけば、通知領域ではその1行が使われます。

{
  "text": "[本番] APIの5xxエラー率が2.4%を超えました",
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "本番アラート: 5xxエラー率" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*環境*\nproduction" },
        { "type": "mrkdwn", "text": "*エラー率*\n2.4% (直近5分)" }
      ]
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "詳細は <https://example.com/dashboards/api|ダッシュボード> を参照してください"
      }
    }
  ]
}

header ブロックは plain_text しか受け付けず、装飾記法は文字列のまま表示されます。太字やリンクを入れる行は sectionmrkdwn 側に置いてください。

リンクとメンションでMarkdownと異なるSlack独自の記法

Slackの整形記法はmrkdwnと呼ばれ、一般的なMarkdownと似て非なるものです。公式のフォーマット仕様では、太字が *bold*、斜体が _italic_、打ち消しが ~strike~ と定義されています。アスタリスク2個の太字は効きません。

差が大きいのはリンクです。角かっこと丸かっこではなく、山かっこの中にURLとパイプ記号、表示文字を並べます。メンションも同じ構文で、ユーザーは <@U012AB3CD>、ユーザーグループは <!subteam^SAZ94GDB8>、全体への呼びかけは <!channel><!here> を使います。ユーザー名を文字列で書いても通知は飛ばず、IDでの指定が要る点が落とし穴です。

アンパサンドと不等号をHTMLエンティティへ置換しないと壊れる条件

山かっことアンパサンドは制御文字として解釈されるため、本文にそのまま含めると表示が崩れます。公式仕様は &&amp;<&lt;>&gt; へ置換するよう求めています。実務で刺さるのは、エラーメッセージやSQLをそのまま通知へ流したとき。WHERE price < 1000 AND stock > 0 のような文字列は、置換せずに送るとリンク記法の一部と誤認され、条件式が消えて届きます。任意の文字列を載せる箇所では、送信直前にこの3文字だけを置換してください。対象を増やすと、今度はコードブロック内の記号が二重エスケープされます。

channelとusernameの実行時上書き廃止で旧サンプルが動かない理由

検索して出てくるコード例の多くは、payload に "channel": "#general""username": "デプロイbot" を含んでいます。これをコピーしても、いま作ったWebhookでは送信先も表示名も変わりません。エラーにもならず、指定が黙って無視されます。

レガシー統合だけが持っていた実行時のchannel上書きの経緯

かつてのIncoming Webhookは、Slackアプリではなく「カスタムインテグレーション」として追加するものでした。レガシー統合のドキュメントには、当時のパラメータとして「channel – override the legacy integration’s default channel」が残っています。

そして同じページが、アプリ版との差分をこう書いています。「the only thing you can no longer do is customize the destination channel and author identity at runtime」。実行時に送信先と送信者を変える機能だけが、意図的に外されました。アイコンや名前はアプリの設定画面で固定値として持たせる形になり、送信側のコードからは触れません。サンプルが動かないのは書き手のミスではなく、前提にしている統合方式が違うためです。

送信先チャンネルを分けるならURLを複数発行して環境変数で持つ設計

現行仕様での解は単純で、チャンネルの数だけ「Add New Webhook to Workspace」を実行し、URLを複数持ちます。設定は SLACK_WEBHOOK_ALERTSLACK_WEBHOOK_DEPLOY のように用途別の環境変数へ分け、宛先名からURLを引く辞書を1箇所に置いてください。避けたいのは、URLを1本だけ持って本文の先頭にチャンネル名を書く折衷案。通知の検索性が落ち、ミュート設定も効かなくなります。

送信先を利用者の入力やデータベースの値から動的に決めたい場合、Incoming Webhookは採用しません。仕様上どう工夫しても満たせない要件なので、ボットトークンと chat.postMessage に切り替えて channel 引数で指定する構成にします。

1秒1メッセージの制限と429・Retry-Afterを前提にした通知設計

個人のテスト送信では見えず、本番で必ず顔を出すのが投稿レートの制限です。監視ツールとつないだ初日にアラートが連続し、途中から届かなくなる、という形で表面化します。

429とRetry-Afterを読んで待つPythonの再送実装

Slackのレート制限ドキュメントは、chat.postMessage でもIncoming Webhookでも同じ制限がかかると説明しています。短いバーストは許容されるものの正確な値は公開されておらず、「design your apps with a limit of 1 request per second」が公式の推奨です。超過時は HTTP 429 と Retry-After ヘッダが返り、ヘッダの値は再試行できるまでの秒数を示します。

import json
import os
import time
import urllib.error
import urllib.request

WEBHOOK_URL = os.environ["SLACK_WEBHOOK_ALERT"]

def post_to_slack(payload, max_retry=3):
    data = json.dumps(payload).encode("utf-8")
    for attempt in range(max_retry):
        req = urllib.request.Request(
            WEBHOOK_URL, data=data,
            headers={"Content-Type": "application/json"},
        )
        try:
            with urllib.request.urlopen(req, timeout=10) as res:
                return res.read().decode("utf-8")  # 成功時は "ok"
        except urllib.error.HTTPError as e:
            if e.code == 429:
                wait = int(e.headers.get("Retry-After", "1"))
                time.sleep(wait)
                continue
            raise
    raise RuntimeError("Slackへの送信がレート制限で完了しませんでした")

Retry-After を無視して固定秒数のスリープを入れると、待ち時間が足りずに429を繰り返します。ヘッダの値をそのまま使うのが最短です。再試行に上限を置くのは、Slack側の障害時に送信処理が無限に詰まるのを避けるため。上限に達した分はログへ落とし、通知経路とは別に追えるようにします。

監視アラートを1通へ集約してバースト送信を避ける実装の判断基準

再送は対症療法で、根本は送りすぎです。効くのは、一定時間のイベントをまとめて1通にする集約。たとえば10秒のウィンドウを置き、その間に発生した失敗をBlock Kitの section に箇条書きで並べ、件数を text の要約に入れます。通知の遅延は最大10秒ですが、レート制限には当たりません。

しきい値は、通知チャンネルを見ている人が1分に何通まで読めるかで決めるのが実際的です。毎分10通を超えるようなら、集約ウィンドウを延ばすか重大度で通知先を分けます。制限はチャンネル単位なので3チャンネルへ配れば3倍の枠を使えますが、同じ内容が並ぶと誰も読まなくなります。配信先を増やす前に、重大度の切り分けを先に決めてください。

Webhookで足りなくなる境界とchat.postMessageへ切り替える判断

Incoming Webhookは「固定のチャンネルへ、一方向に、投げっぱなしで送る」用途に絞られた仕組みです。そこから外れる要件が入ると、実装が急に苦しくなります。

Webhookの応答にtsが返らないため更新も返信もできない制約

Webhookの応答は文字列 ok だけ。一方で chat.postMessage は、投稿したメッセージのタイムスタンプID(ts)とチャンネルを含むJSONを返します。この ts が、後続操作のすべての鍵になります。

デプロイの進捗を1つのメッセージで書き換えていく、障害の続報を同じスレッドにぶら下げる、完了した通知を削除する。いずれも ts を保持していなければ実行できません。公式リファレンスは thread_ts を渡せば親メッセージへの返信として投稿できると説明していますが、その親の ts はWebhookから取得できないわけです。「あとでスレッドにまとめたい」という要望が出た時点で、作り替えが確定します。

Incoming Webhookとchat.postMessageの機能差の比較表

選定を早く終わらせるために、実装の可否で並べます。認証方式と返り値の差が、そのまま機能差になっています。

観点 Incoming Webhook chat.postMessage
認証 URL自体がシークレット ボットトークン
送信先の指定 URLごとに固定 channel引数で指定
応答 文字列 ok のみ ts を含むJSON
更新・削除 不可 ts を使って可能
スレッド返信 不可 thread_ts で可能
投稿レート 1秒1件/チャンネル 1秒1件/チャンネル
導入の手数 URLを1本発行するだけ スコープ設定が必要

判断は要件の一点で決まります。投げっぱなしでよいならWebhook、投げた後に何かするならトークン方式。両方を併用すると同じ通知が2系統に散るので、1つの通知系統ではどちらかに寄せてください。なお受信側を自作する設計は論点が別にあり、署名検証や再送への対処はWebhookの実装方法と作り方で扱っています。

Webhook URLの秘匿と漏洩時の作り直しを含む運用の引き継ぎ設計

URLがそのまま認証情報である以上、扱いはAPIキーと同じです。公式ガイドは「Your webhook URL contains a secret. Don’t share it online, including via public version control repositories.」と警告し、「Slack actively searches out and revokes leaked secrets.」と続けています。公開リポジトリへ誤ってコミットすると、Slack側が検知してURLを失効させることがあります。CI/CDから送る場合は、リポジトリのシークレット機能から環境変数に渡し、ワークフロー定義にURLを書きません。

name: notify-deploy
on:
  workflow_run:
    workflows: ["deploy"]
    types: [completed]

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Post result to Slack
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_DEPLOY }}
        run: |
          curl -sS -X POST "$SLACK_WEBHOOK_URL" \
            -H "Content-type: application/json" \
            --data "{\"text\": \"deploy ${{ github.event.workflow_run.conclusion }}\"}"

引き継ぎで効くのは、どのURLがどのチャンネルに紐づくかの一覧です。アプリ名・Webhookの識別子・送信先チャンネル・利用システムの4つを台帳に持っておくと、担当者の交代時に「何に使われているか分からないので消せない」という状態を避けられました。委託先の開発が終わった時点で、該当のWebhookを削除して発行し直すのが安全側の運用です。通知系統が複数のシステムへ分散して管理しきれない場合は、連携基盤ごと整理する段階で、API開発・システム連携のような外部の設計支援も選択肢に入ります。

よくある質問

Slack Webhookの実装で問い合わせが多い論点を、公式ドキュメントの記述に沿って整理します。

Webhook URLは無料プランでも発行できますか?

発行できます。Incoming Webhookの利用自体に有料プランの条件は課されておらず、無料プランのワークスペースでもSlackアプリを作成してURLを払い出せます。制約が出るのはワークスペース側の設定で、インストールに管理者の承認が必要なら申請してください。過去の通知を長期間さかのぼりたい用途では、無料プランの保存期間の条件も先に確認が要ります。

payloadでchannelを指定しても投稿先が変わらないのはなぜですか?

Slackアプリ版のIncoming Webhookでは、公式ドキュメントが送信先チャンネル・ユーザー名・アイコンを実行時に上書きできないと明記しているためです。指定はエラーにならず無視されます。上書きが可能だったのはレガシーのカスタムインテグレーションで、古いサンプルはその前提で書かれたもの。別のチャンネルへ送るなら、設定画面から2本目のWebhookを追加してURLを分けてください。

Webhook URLが外部に漏れた場合はどう対処しますか?

該当のWebhookをアプリ管理画面から削除し、新しいURLを発行し直します。URLは認証情報そのもので、知っている相手は誰でもそのチャンネルへ投稿できるため、パスワード変更と同じ扱いで即座に無効化するのが原則です。公開リポジトリへコミットした場合は、履歴からの削除より先にURLの再発行を済ませてください。

1つのWebhookで複数チャンネルへ同時に通知できますか?

できません。1本のURLは1チャンネルに固定されるため、3つのチャンネルへ配るなら3本のURLへそれぞれPOSTします。レート制限はチャンネル単位で判定されるので、同時送信そのものは問題になりません。ただし同じ内容を複数の場所へ流すと読まれなくなるため、配信先を増やす前に重大度による出し分けを設計してください。

Webhookで送ったメッセージをあとから編集・削除できますか?

Incoming Webhookの応答には、メッセージを特定するタイムスタンプID(ts)が含まれないため、プログラムからの編集も削除もできません。手作業でSlack上から消すことは可能ですが、自動処理には組み込めない仕様です。進捗に応じて書き換えたい要件がある場合は、chat:write スコープのボットトークンを取得し、chat.postMessage が返す ts を保存して chat.update や chat.delete を呼ぶ構成にしてください。

関連記事

資料請求

RELATED POSTS 関連記事