開発者向け
Webhook Beta
連絡先、会話、メッセージ、タグ、チームのメンバーシップが変わったときに、確実に届く署名付きの通知を受け取れます。
APIリファレンスを開くエンドポイントを作成する
- 01
設定 を開いて 開発者 を選択し、Webhook を探して エンドポイントを追加 を選択します。
- 02
名前 と公開されたHTTPSの エンドポイントURL を入力し、イベント で1つ以上の項目を選びます。
- 03
エンドポイントを追加 を選択します。
POST /api/v1/webhooksで作成することもできます。
Sonnyは、認証情報を含むURL、localhost、プライベート/リンクローカルのIP範囲、および非公開アドレスに解決されるDNS名を拒否します。
署名シークレットをすぐに保存する
シークレットは whsec_ で始まり、一度だけ表示されます。保存しました を選択する前に、シークレットマネージャーにコピーしてください。
エンドポイントを選択したチャネルに限定する
設定 → 開発者 では、エンドポイントの追加時も後からの編集時も、すべてのチャネルを受信するか、選んだチャネルだけを受信するかを設定できます。チャネルの限定機能ができる前に作成したエンドポイントは、変更するまですべてのチャネルを受信します。
APIとMCPのクライアントでは、エンドポイントの作成時または更新時に sourceIds で同じ絞り込みを設定します。空の配列は、ワークスペースのすべてのソースを意味します。IDを指定すると、会話とメッセージのイベントは、その会話が選択したソースのいずれかに属する場合にのみ配信されます。連絡先やメンバーシップの変更など、ワークスペース単位のイベントは、ソースで絞り込んだエンドポイントには送信されません。
{
"name": "Product A agent",
"url": "https://agent.example.com/sonny",
"events": ["conversation.created", "message.created"],
"sourceIds": ["cm_source_id"]
}連携で使わないものは配信しない
APIを通じて返信するエージェントは、特に指定しない限り、自分自身の返信で起動されます。3つの任意の絞り込みで、エンドポイントが受け取るものを絞れます。すべてデフォルトでオフなので、既存のエンドポイントは変わりません。
agentGroupIds- 指定したエージェントグループが担当する会話のみ。会話が届いたチャネルではなく担当に従うため、共有の受信トレイから来た会話は、それを担当するグループに届きます。グループのない会話は配信されません。また、グループは通常会話の開始後に割り当てられるため、
conversation.createdは一致するグループができる前に発生することがよくあります。 customerMessagesOnly- 顧客が書いたメッセージのみ。チームからの返信、社内メモ、API、MCP、AI応答から送られたもの(このエンドポイント自身の返信を含む)はスキップされます。
excludedSenderIds- 指定したメンバーが送ったメッセージをスキップします。連携専用のメンバーアカウントを用意してそれを除外すれば、自分の返信では起動せず、人間が会話を引き継いだときには通知を受け取れます。AIエージェントが身を引くために必要なシグナルです。
メッセージの絞り込みは message.* イベントにのみ適用されます。それ以外はイベントの種類で制御します。絞り込まれたイベントは配信になる前に破棄されるため、コストはかからず、失敗として表示されることもありません。どちらの場合も、ペイロードと apiVersion は変わりません。
連続するメッセージを1回の配信にまとめる
起動時にスレッド全体を読むコンシューマーにとって、10秒間に4回別々に配信されても得るものはありません。coalesceSeconds を設定すると、1つの会話のメッセージがその時間だけ集められ、1回の配信として送信されます。デフォルトではオフで、設定していないエンドポイントは引き続きメッセージごとに受け取ります。
まとめられた配信は、会話と集められたすべてのメッセージIDを含む conversation.activity として届くため、メッセージごとではなく一度だけスレッドを読んでください。受け取る内容の形が変わる唯一の設定なので、オプトイン方式になっています。ほかの絞り込みも引き続き適用され、除外されたメッセージがグループに加わることはありません。会話ごとに独自の時間枠があり、再試行ではグループが1つの配信として扱われます。
{
"type": "conversation.activity",
"data": {
"conversationId": "cm_conversation_id",
"messageIds": ["cm_first_message", "cm_second_message"]
}
}Bearer認証やAPIキー認証の背後にあるエンドポイント
受信側がヘッダーを必要とするゲートウェイの背後にある場合は、エンドポイントの作成時または編集時に カスタムヘッダー で追加するか、APIから headers を送信します。値は保存時に暗号化され、返されることはありません。保存済みのヘッダーは名前だけが返され、その名前だけを再送信すると保存済みの値が維持されます。すべてのヘッダーを削除するには、空の配列を送ります。
エンドポイントを別のホストに移すと再利用は解除され、保存済みの値を再入力する必要があります。認証情報が、発行された先以外に転送されることはありません。パスだけを変更した場合は維持されます。
Sonny自身のヘッダーが優先されるため、カスタムヘッダーで sonny-signature、content-type、配信IDのヘッダーを置き換えることはできません。可能であれば署名の検証をおすすめします。署名はすべてのペイロードを認証しますが、固定のトークンは呼び出し元を識別するだけです。
{
"name": "Gateway",
"url": "https://api.example.com/hooks/sonny",
"events": ["conversation.created"],
"headers": [{ "name": "Authorization", "value": "Bearer …" }]
}セキュリティと信頼性
- 署名付きの生のボディ
- HMAC-SHA256は、Unixタイムスタンプ、ドット、そして加工されていないUTF-8のリクエストボディを対象にします。
- リプレイ攻撃への対策
- HMACが有効でも、5分以上過去または未来のタイムスタンプは拒否してください。
- 確実な再試行
- 2xx以外のレスポンスは、1m, 5m, 30m, 2h, 6h 後に再試行されます。6 回目の試行が最後です。
リクエストの仕様
sonny-signature- t=<unix-seconds>,v1=<sha256-hex>
sonny-event- 高速な振り分けのためのイベントの種類。
sonny-delivery-id- 冪等性とサポートのための固定のID。
user-agent- Sonny-Webhooks/1.0
{
"id": "cm_event_id",
"type": "message.created",
"apiVersion": "2026-07-15",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": {
"conversationId": "cm_conversation_id",
"messageId": "cm_message_id"
}
}署名を検証する
まず生のボディを読み取ってください。JSONをパースしてから再度シリアライズすると空白が変わり、有効な署名でも検証に失敗します。
import { createHmac, timingSafeEqual } from "node:crypto";
const rawBody = await request.text(); // do not parse/re-serialize first
const signature = request.headers.get("sonny-signature");
const secret = process.env.SONNY_WEBHOOK_SECRET;
if (!signature || !secret) throw new Error("Missing webhook signature or secret");
const { t, v1 } = Object.fromEntries(signature.split(",").map(p => p.split("=")));
if (!/^\d+$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) {
throw new Error("Malformed signature");
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
throw new Error("Stale webhook");
}
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
throw new Error("Invalid signature");
}イベントカタログ
webhook.heartbeat新しいメッセージがなくても、有効な購読者(* を含む)に5分ごとに送信されます。通常の署名付き配信・再試行の仕組みを使い、会話フィルターは無視されます。ハートビートが届かない、または古い場合にアラートを出してください。返信待ちの会話を検出するものではありません。
{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}contact.created連絡先が作成されました。
{"contactId":"cm_contact_id"}contact.updated連絡先が更新または統合されました。
{"contactId":"cm_contact_id"}contact.deleted連絡先がアーカイブされたか、別の連絡先に統合されました。archived=trueを指定すれば引き続き取得できます。
{"contactId":"cm_contact_id"}contact.erased連絡先が、すべての会話、メッセージ、添付ファイルとともに完全に消去されました(GDPRの消去リクエストなど)。今後は取得できません。保持しているコピーはすべて削除してください。
{"contactId":"cm_contact_id"}conversation.created会話が作成されました。
{"conversationId":"cm_conversation_id"}conversation.updated会話が変更されました。
{"conversationId":"cm_conversation_id"}conversation.closed会話がクローズされました。
{"conversationId":"cm_conversation_id"}conversation.deleted会話がゴミ箱に移動され、公開APIから除外されました。
{"conversationId":"cm_conversation_id"}message.createdメッセージまたは社内メモが作成されました。可能な場合はコンテキストと短いメッセージのプレビューが含まれます。社内メモの本文は含まれません。
{"conversationId":"cm_conversation_id","messageId":"cm_message_id","context":{"sourceId":"cm_source_id","sourceName":"Shopstar Go","storeName":"Shiney Store","inboxId":"cm_inbox_id","inboxName":"Go support","agentGroupId":"cm_group_id","agentGroupName":"Support","identityVerified":true,"status":"open","lastRepliedAt":"2026-09-25T09:00:00.000Z"},"message":{"id":"cm_message_id","type":"incoming","senderType":"human","senderId":null,"senderName":"Shiney","textPreview":"Could you check my bill?","textTruncated":false}}message.updated送信済みの返信が会話履歴上で編集されました。配信済みのメールは変更されません。
{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}tag.createdタグが作成されました。
{"tagId":"cm_tag_id"}tag.updatedタグが更新されました。
{"tagId":"cm_tag_id"}tag.deletedタグが削除されました。
{"tagId":"cm_tag_id"}member.invitedワークスペースのメンバーが招待されました。
{"invitationId":"cm_invitation_id"}member.updatedメンバーのロールまたはステータスが変更されました。
{"memberId":"cm_membership_id"}member.removedメンバーが削除されました。
{"memberId":"cm_membership_id"}invitation.acceptedワークスペースへの招待が承諾されました。
{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}invitation.cancelled保留中のワークスペースへの招待がキャンセルされました。
{"invitationId":"cm_invitation_id"}conversation.activity1つの会話のメッセージを1回の配信にまとめたものです。可能な場合は各メッセージのスナップショットが含まれます。まとめ配信の期間を設定したエンドポイントにmessage.createdの代わりに送信され、直接購読することはできません。
{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}webhook.test管理者がリクエストしたテストイベントです。
{"message":"This is a test webhook from Sonny."}
配信の動作
- 10秒以内に任意の2xxステータスを返すと、配信は成功として記録されます。
- ハンドラーは冪等にしてください。重複を無視するには、イベントの
idまたはsonny-delivery-idを使います。 - リダイレクトには従いません。代わりにSonnyでエンドポイントURLを更新してください。
- レスポンスボディにはサイズの上限があり、診断用に最初の4KiBだけが保持されます。
- テスト を選択すると、
webhook.testイベントが送信されます。配信 を選択すると、最新の50件のイベントを確認できます。配信が最終的に失敗した場合は、再試行 を選択して再送信できます。
フィードの停止を検知する
開発者設定、または update_webhook で、エンドポイントのイベントに webhook.heartbeat を追加します。有効な購読者(* を含む)には、新しいメッセージがなくても5分ごとに署名付きのハートビートが届きます。ハートビートはチャネル、チーム、メッセージの絞り込みを無視し、会話のデータは含みません。メッセージと同じ配信経路と再試行を使います。古い再試行が新しいハートビートに見えないよう、createdAt と nextExpectedAt を確認し、アラートを出す前にポーリングやネットワークの遅延を考慮してください。配信が滞っていると、ハートビートが遅れたり届かなかったりする場合があります。
webhooks:read を指定して list_webhook_deliveries を使うと、試行と失敗を確認できます。get_status が確認するのはデータベースの接続で、Webhookの配信ではありません。フィードが静かな間も古い未返信のスレッドを見つけられるよう、status=open、awaitingReply=true、sort=waitingSince、direction=asc を指定した list_conversations を別途定期実行してください。
開発者設定で「コンパクトなペイロード」を有効にするか、エンドポイントで compact=true を設定すると、振り分け用のID、送信者、時刻、200文字のメッセージプレビューを残したまま、文脈のラベルを省略できます。これはまとめられた配信にも適用され、社内メモの本文は引き続き除外されます。デフォルトのペイロードは、メッセージのタイムスタンプが追加された以外は変わりません。配信ログと連絡先の直接検索のために、既存のAPIキーのスコープを編集して webhooks:read と contacts:read を付与してください。どちらのスコープにも、すべてのチャネルにアクセスできるキーと、すべてのチャネルにアクセスできるメンバーが必要です。スコープが限定されたキーは、権限を追加するだけでは範囲を広げられません。