「TeamsのIncoming Webhook経由で特定ユーザーへDMを送りたい」——多くの管理者・開発者が一度はぶつかる壁です。結論は明確で、Webhookはチャンネル専用。個人チャット(1:1)へは届きません。本記事では、その理由と2025年時点の正解(Teamsボット/Microsoft GraphのDelegated)を、実装手順・コード例・運用設計まで一気通貫で解説します。
Teams Incoming Webhook では個人チャットに送れない理由と正解ルート
Incoming Webhook は Teams の「チャンネル」に紐づくコネクタであり、投稿先のスコープは常にチャネル会話です。個人チャット(1:1)にはスコープ外のため送信できません。ここを無理に迂回しようとしても、HTTP 200 が返っても相手の1:1には届かない、という結果になります。
| 送信先 | Incoming Webhook可否 | 代替手段 | 備考 |
|---|---|---|---|
| Teams チャンネル | 可 | Incoming Webhook / Bot / Graph | チームの任意チャネルに投稿可能 |
| 個人チャット(1:1) | 不可 | Teamsボット(プロアクティブ) / Graph(Delegated) | Webhookはスコープ外 |
| グループチャット(複数人) | 不可 | ボット / Graph(Delegated) | Graphはチャット作成後に投稿 |
個人宛て通知の正攻法は次の2つです。
- Teams ボット(Azure Bot Service + Bot Framework + Teams SDK)でプロアクティブ メッセージを送る。
- Microsoft Graph API(Delegated)で、サインイン済みユーザーの権限でチャットを作成・投稿する。
Graph API で 1:1 を送るときの基本(Delegated が必須)
Graph では /chats/{chat-id}/messages に POST することでチャットへ投稿できます。ただし 1:1 送信はユーザー委任(Delegated)のアクセストークンが必須です。アプリのみ(App-only)トークンでは 1:1 の通常投稿はブロックされ、「外部インポート用途」に限定されます。
| 項目 | 要点 |
|---|---|
| 必要スコープ | Chat.ReadWrite または Chat.ReadWrite.All(Delegated) |
| できないこと | App-only での 1:1 通常投稿(インポート用途を除く) |
| 基本手順 | ① ユーザーでサインインしトークン取得 → ② 相手ユーザーIDを取得 → ③ 1:1チャットを取得 or 作成(chatType=oneOnOne)→ ④ /chats/{chat-id}/messages へ POST |
PowerShell(Invoke‑RestMethod)による最小実装の流れ(検証用)
以下は概念実証(PoC)向けの最短ルートです。運用では安全なフロー(デバイスコード/認可コード)への置き換えを推奨します。
$tenantId = "<tenant-id>"
$clientId = "<public-client-id>" # モバイル/デスクトップ向けパブリッククライアント
$username = "<user-upn>"
$password = "<password>" # ROPCは非推奨。検証用途限定。
# 1) ユーザートークン取得(ROPC例:非推奨)
$tokenBody = @{
client_id = $clientId
scope = "[https://graph.microsoft.com/.default](https://graph.microsoft.com/.default) offline_access openid profile"
grant_type = "password"
username = $username
password = $password
}
$token = Invoke-RestMethod -Method Post ` -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"`
-Body $tokenBody
$accessToken = $token.access_token
# 2) 相手ユーザーを取得(メール/UPNからIDへ)
$targetUser = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} `
-Uri "[https://graph.microsoft.com/v1.0/users/<target-user-upn-or-mail](https://graph.microsoft.com/v1.0/users/<target-user-upn-or-mail)>"
$targetUserId = $targetUser.id
# 3) 1:1チャットを作成(存在しなければ生成)
$chatCreateBody = @{
chatType = "oneOnOne"
members = @(
@{
"@odata.type" = "#microsoft.graph.aadUserConversationMember"
"roles" = @("owner")
"[[email protected]](mailto:[email protected])" = "[https://graph.microsoft.com/v1.0/me](https://graph.microsoft.com/v1.0/me)"
},
@{
"@odata.type" = "#microsoft.graph.aadUserConversationMember"
"roles" = @("owner")
"[[email protected]](mailto:[email protected])" = "[https://graph.microsoft.com/v1.0/users('$targetUserId](https://graph.microsoft.com/v1.0/users%28'$targetUserId)')"
}
)
} | ConvertTo-Json -Depth 5
$chat = Invoke-RestMethod -Method Post -Headers @{Authorization="Bearer $accessToken";"Content-Type"="application/json"} `
-Uri "[https://graph.microsoft.com/v1.0/chats](https://graph.microsoft.com/v1.0/chats)" -Body $chatCreateBody
$chatId = $chat.id
# 4) メッセージを送信(HTML可)
$msgBody = @{
body = @{
contentType = "html"
content = "自動通知です。
詳細はポータルを確認してください。"
}
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers @{Authorization="Bearer $accessToken";"Content-Type"="application/json"} `
-Uri "[https://graph.microsoft.com/v1.0/chats/$chatId/messages](https://graph.microsoft.com/v1.0/chats/$chatId/messages)" -Body $msgBody
注意: ROPC はセキュリティとポリシー面の制約が大きく、実運用ではデバイスコードや認可コード+PKCE等へ移行してください。
cURL(Delegatedトークンがある前提)
# チャット作成
curl -X POST https://graph.microsoft.com/v1.0/chats \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"chatType":"oneOnOne",
"members":[
{"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":["owner"],"[email protected]":"https://graph.microsoft.com/v1.0/me"},
{"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":["owner"],"[email protected]":"https://graph.microsoft.com/v1.0/users('\''<target-user-id>'\'' )"}
]
}'
# メッセージ送信
curl -X POST [https://graph.microsoft.com/v1.0/chats/<chat-id>/messages](https://graph.microsoft.com/v1.0/chats/<chat-id>/messages)
-H "Authorization: Bearer "
-H "Content-Type: application/json"
-d '{"body":{"contentType":"html","content":"こんにちは"}}'
よくあるエラーと対処
| エラー例 | 原因 | 対処 |
|---|---|---|
| 401 Unauthorized | トークン不正、スコープ不足 | Delegated で Chat.ReadWrite を付与して再発行 |
| Requested API is not supported in application‑only context | App-only トークンで呼び出し | Delegated に切替、またはボットへ設計変更 |
| 403 Forbidden | 組織ポリシー/ライセンス/レプリカ遅延 | Teams ライセンス、メッセージングポリシー、外部アクセス設定を確認 |
| 404 Not Found(チャットID関連) | チャット未作成/IDミス | POST /chats で 1:1 を作成してから送信 |
| 400 Bad Request(HTML不正等) | サポート外タグ・本文サイズ超過 | プレーンテキストで検証、本文を短く/改行を整理 |
「メッセージ送信」と「返信」の違い(個人チャットは返信APIなし)
| 操作 | 現状の可否 | 備考 |
|---|---|---|
| 1:1チャットに新規メッセージ送信 | 可能(Delegated) | /chats/{chat-id}/messages に POST |
| 既存メッセージにスレッド返信 | 未対応(個人チャット) | 返信APIはチャンネル会話用に限定 |
Teams ボットでのプロアクティブ メッセージが最も確実
サーバー側からユーザーの操作なしに 1:1 を確実に送るなら、Teams ボットが王道です。ポイントは「ボットがユーザーの個人スコープにインストールされている」ことと、「会話リファレンス(ConversationReference)を保持」することです。
ボットの標準フロー
- Azure Bot Service でボット登録(チャネルに Teams を追加)。
- ユーザーまたは管理者がボットを個人スコープにインストール(アプリ配布/自動配置ポリシー)。
- 初回起動やインストールイベントで ConversationReference を保管(DB 等)。
- バックエンドから任意タイミングで proactive に送信。
C#(Bot Framework v4)概略コード
public class ProactiveSender
{
private readonly BotFrameworkAdapter _adapter;
private readonly string _appId; // Bot AppId
private readonly string _serviceUrl; // 既知の serviceUrl(会話から取得して保存)
public ProactiveSender(BotFrameworkAdapter adapter, string appId, string serviceUrl)
{
_adapter = adapter; _appId = appId; _serviceUrl = serviceUrl;
}
public async Task SendToUserAsync(string tenantId, string userAadObjectId, string message, CancellationToken ct)
{
var user = new ChannelAccount(id: userAadObjectId);
var bot = new ChannelAccount(id: _appId);
var parameters = new ConversationParameters
{
IsGroup = false,
Bot = bot,
Members = new List<ChannelAccount> { user },
TenantId = tenantId
};
await _adapter.CreateConversationAsync(
channelId: "msteams",
serviceUrl: _serviceUrl,
credentials: null,
parameters: parameters,
callback: async (turnContext, cancellationToken) =>
{
await turnContext.SendActivityAsync(MessageFactory.Text(message), cancellationToken);
},
cancellationToken: ct);
}
}
実運用では、ユーザーごとに保存した ConversationReference を使って ContinueConversationAsync で送るパターンが安定します。インストールされていないユーザーには送れないため、配布ポリシーや事前インストールの設計が肝です。
2025年時点のベストプラクティスと選定表
| 目的 | 推奨アプローチ | 理由 / 注意点 |
|---|---|---|
| サーバーから自動通知(ユーザーログイン不要) | Teams ボット(プロアクティブ) | 1:1宛てを公式に・確実に送れる。インストール必須。料金・審査考慮。 |
| サインイン済みクライアントから送信 | Graph API(Delegated) | UIから自然に送れる。Chat.ReadWrite スコープが必要。 |
| バックグラウンドApp-onlyで1:1送信 | 現状不可 | 通常投稿は不可。インポート用途に限定。要件次第でボットへ転換。 |
必要権限と代表エンドポイントの整理
| シナリオ | 主な権限/スコープ | エンドポイント例 |
|---|---|---|
| Teams ボットから 1:1 | ボット登録時の権限(ユーザー/チャットの読み書き) | SDK内部(CreateConversationAsync → SendActivityAsync) |
| Graph(Delegated) | Chat.ReadWrite, Chat.ReadWrite.All | GET /users/{id}POST /chats(chatType=oneOnOne)POST /chats/{chat-id}/messages |
| Graph(App-only) | ChatMessage.Send など(管理者同意) | /chats/{chat-id}/messages(インポート用途) |
実装ディテール:Graph Delegated を使う最短ルート
ステップ1:ユーザーでサインインしてトークンを取る
- 本番は「認可コード + PKCE」または「デバイスコード」推奨。
- 検証では ROPC を使えるが、条件付きアクセスやMFAに阻まれる場合が多い。
ステップ2:相手のユーザーID(Entra ID オブジェクトID)を取得
GET /v1.0/users/{mail-or-upn}
→ レスポンスの id を使用
ステップ3:1:1チャットを作成 or 既存を利用
既存検索は GET /me/chats?$filter=chatType eq 'oneOnOne' を列挙し、メンバーに対象IDが含まれるかで判定。なければ POST /chats で作成します。
ステップ4:メッセージ送信
POST /v1.0/chats/{chat-id}/messages
{
"body": {
"contentType": "html",
"content": "通知本文 <br/> 詳細はポータルで確認してください。"
}
}
Teams ボット設計の要点(プロアクティブ)
- インストール要件:ユーザーの個人スコープにボットが入っていないとDM不可。
- 会話リファレンスの保存:ユーザーごとに最新の
serviceUrl/conversationを安全に保管。 - 多言語・リッチカード:Adaptive Cardsで配信可。既読は Signal ではなくイベント/Graph で取得検討。
- 配布:テナント内配布、ストア審査、App Setup Policy による自動配布など運用決定。
Webhook / Graph / ボットの比較(意思決定の早見表)
| 観点 | Incoming Webhook | Graph(Delegated) | Teams ボット |
|---|---|---|---|
| 送信先 | チャンネルのみ | 1:1 / グループ / チャンネル(要権限) | 1:1 / グループ / チャンネル |
| ユーザーログイン不要 | 可 | 不可(Delegated必須) | 可 |
| 公式サポートの確実性 | 高(チャンネル限定) | 高(要件に合致すれば) | 最高(DM通知の王道) |
| 実装の難易度 | 低 | 中 | 中〜高 |
| 拡張性(カード/アクション) | 低 | 中 | 高 |
運用で効くチェックリスト
- 配信保証:再送・リトライ(429/5xx)と冪等キーを設計する。
- 到達可視化:配信ログ、失敗の分類(ポリシー・無インストール・無権限・容量超過)。
- レート制御:バーストを避け、キューで平滑化。チャット作成はキャッシュ。
- 本文制限:メッセージ長、HTMLタグ制限、添付/メンション数を上限内に。
- セキュリティ:最小権限、シークレット運用、監査ログ、退職者配信の抑止。
- コンプライアンス:保持/監査/情報保護ラベルの方針に沿う。
トラブルシューティング深掘り
「送れたり送れなかったり」問題
- ボットが未インストールのユーザーは失敗。自動配布ポリシーを活用。
- Graph Delegated はユーザー側の Teams ポリシーの影響を受ける。
- 相手が外部組織の場合、外部アクセス設定やフェデレーションの影響がある。
メンション・カードが崩れる
- メンションは
<at id="0">太郎</at>形式とmentions配列を一致させる。 - Adaptive Cards はバージョン差異に注意。プレーンテキストでまず通るか確認。
監査・可観測性
- メッセージID、チャットID、ユーザーID、コリレーションIDをログ化。
- SLI/SLO を定義(到達率、遅延、リトライ率)。
サンプル:Python(requests)で 1:1 を送る
import requests
access_token = ""
target_user_id = ""
# チャット作成
chat_payload = {
"chatType": "oneOnOne",
"members": [
{"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":["owner"],"[[email protected]](mailto:[email protected])":"[https://graph.microsoft.com/v1.0/me](https://graph.microsoft.com/v1.0/me)"},
{"@odata.type":"#microsoft.graph.aadUserConversationMember","roles":["owner"],"[[email protected]](mailto:[email protected])":f"[https://graph.microsoft.com/v1.0/users('{target_user_id}')](https://graph.microsoft.com/v1.0/users%28'{target_user_id}'%29)"}
]
}
r = requests.post("[https://graph.microsoft.com/v1.0/chats](https://graph.microsoft.com/v1.0/chats)",
headers={"Authorization": f"Bearer {access_token}", "Content-Type":"application/json"},
json=chat_payload)
r.raise_for_status()
chat_id = r.json()["id"]
# メッセージ送信
msg = {"body":{"contentType":"html","content":"Pythonからの自動通知です。"}}
r = requests.post(f"[https://graph.microsoft.com/v1.0/chats/{chat_id}/messages](https://graph.microsoft.com/v1.0/chats/{chat_id}/messages)",
headers={"Authorization": f"Bearer {access_token}", "Content-Type":"application/json"},
json=msg)
r.raise_for_status()
サンプル:C#(Graph SDK, Delegated)
using Microsoft.Graph;
using Microsoft.Kiota.Abstractions.Authentication;
using Azure.Identity;
// 対話式(デバイスコード等)でトークンを取得
var scopes = new[] { "Chat.ReadWrite" };
var options = new DeviceCodeCredentialOptions { ClientId = "", TenantId = "" };
var credential = new DeviceCodeCredential(o => { Console.WriteLine(o.Message); return Task.CompletedTask; }, options);
var authProvider = new TokenCredentialAuthenticationProvider(credential, scopes);
var graph = new GraphServiceClient(authProvider);
// 相手ユーザー
var user = await graph.Users[""].GetAsync();
// 1:1チャット作成
var chat = await graph.Chats.PostAsync(new Chat
{
ChatType = ChatType.OneOnOne,
Members = new List
{
new AadUserConversationMember { Roles = new List { "owner" }, AdditionalData = new Dictionary { ["[[email protected]](mailto:[email protected])"] = "[https://graph.microsoft.com/v1.0/me](https://graph.microsoft.com/v1.0/me)" } },
new AadUserConversationMember { Roles = new List { "owner" }, AdditionalData = new Dictionary { ["[[email protected]](mailto:[email protected])"] = $"[https://graph.microsoft.com/v1.0/users('{user.Id}](https://graph.microsoft.com/v1.0/users%28'{user.Id})')" } }
}
});
// メッセージ
await graph.Chats[chat.Id].Messages.PostAsync(new ChatMessage
{
Body = new ItemBody { ContentType = BodyType.Html, Content = "C# Graph SDK からの通知" }
});
セキュリティ/ガバナンス上の留意点
- 最小権限:不要なスコープは付与しない。管理者同意の審査をクリアする。
- 秘密情報の管理:キー/証明書のローテーション、キーバックアップ、監査。
- ユーザー保護:配信頻度・時間帯を制御し、迷惑通知化を避ける。
- コンプライアンス:保持/監査/データ主権に準拠。PIIの扱いに注意。
FAQ(実案件でよく聞かれること)
- Q: Webhook の URL を個人チャットに向け替えられませんか?
A: できません。Webhook はチャンネルのリソースに結びついています。 - Q: App-only でどうにか 1:1 を送りたい。
A: 通常投稿は不可です。ボットでの配信に設計を切り替えるのが現実解です。 - Q: 返信(スレッド)で送りたい。
A: 1:1 では返信APIがありません。メッセージとして新規投稿してください。 - Q: 既存チャットが大量にあると検索が重い。
A: 「必要なら作成」方針でPOST /chatsを先に打ち、ID をキャッシュすると軽くなります。
まとめ:設計の指針
- Webhookでは1:1に送れない——ここは仕様として割り切る。
- サーバー主導のDM通知はボットが最適解(プロアクティブ)。
- クライアント発の送信はGraph Delegatedが簡潔で扱いやすい。
- App-only での 1:1 は現状方針外。要件とセキュリティを踏まえて構成を選ぶ。
- 運用はレート制御・配信保証・監査まで設計して初めて安定稼働。
付録:コピーして使える PowerShell スニペット集
Access Token(デバイスコード例:ユーザーにブラウザ表示)
# MSAL.PS を使う例(事前に Install-Module MSAL.PS)
$token = Get-MsalToken -ClientId "<client-id>" -TenantId "<tenant-id>" -Scopes "Chat.ReadWrite" -DeviceCode
$accessToken = $token.AccessToken
1:1 チャット作成 → 送信
$headers = @{ Authorization = "Bearer $accessToken"; "Content-Type"="application/json" }
# ユーザーIDを UPN から取得
$user = Invoke-RestMethod -Headers $headers -Uri "[https://graph.microsoft.com/v1.0/users/<target-upn](https://graph.microsoft.com/v1.0/users/<target-upn)>"
# 作成
$body = @{
chatType = "oneOnOne"
members = @(
@{ "@odata.type"="#microsoft.graph.aadUserConversationMember"; roles=@("owner"); "[[email protected]](mailto:[email protected])"="[https://graph.microsoft.com/v1.0/me](https://graph.microsoft.com/v1.0/me)" },
@{ "@odata.type"="#microsoft.graph.aadUserConversationMember"; roles=@("owner"); "[[email protected]](mailto:[email protected])"="[https://graph.microsoft.com/v1.0/users('$($user.id)](https://graph.microsoft.com/v1.0/users%28'$%28$user.id%29)')" }
)
} | ConvertTo-Json -Depth 5
$chat = Invoke-RestMethod -Method Post -Headers $headers -Uri "[https://graph.microsoft.com/v1.0/chats](https://graph.microsoft.com/v1.0/chats)" -Body $body
# 送信
$msg = @{ body = @{ contentType="html"; content="PowerShell からの通知"; } } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers $headers -Uri "[https://graph.microsoft.com/v1.0/chats/$($chat.id)/messages](https://graph.microsoft.com/v1.0/chats/$%28$chat.id%29/messages)" -Body $msg
最後に
仕様は変化が速く、プラットフォームの制限やポリシーも更新されます。実装前に必ず最新のドキュメントとテナント側のポリシーを点検し、PoC→段階的展開→本番化の順でリスクを抑えていきましょう。個人宛て通知は「ボットが王道、Graph Delegatedが堅実」、この方針で設計すれば迷いません。

コメント