開発者向け

Webhook Beta

連絡先、会話、メッセージ、タグ、チームのメンバーシップが変わったときに、確実に届く署名付きの通知を受け取れます。

APIリファレンスを開く

エンドポイントを作成する

  1. 01

    設定 を開いて 開発者 を選択し、Webhook を探して エンドポイントを追加 を選択します。

  2. 02

    名前 と公開されたHTTPSの エンドポイントURL を入力し、イベント で1つ以上の項目を選びます。

  3. 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.activity

1つの会話のメッセージを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 を付与してください。どちらのスコープにも、すべてのチャネルにアクセスできるキーと、すべてのチャネルにアクセスできるメンバーが必要です。スコープが限定されたキーは、権限を追加するだけでは範囲を広げられません。

関連ドキュメント