นักพัฒนา

เว็บฮุก Beta

รับการแจ้งเตือนที่มีลายเซ็นกำกับและเชื่อถือได้ เมื่อผู้ติดต่อ บทสนทนา ข้อความ แท็ก หรือสมาชิกในทีมมีการเปลี่ยนแปลง

เปิดเอกสารอ้างอิง API

สร้างเอนด์พอยต์

  1. 01

    เปิด การตั้งค่า เลือก นักพัฒนา หา เว็บฮุก แล้วเลือก เพิ่มเอนด์พอยต์

  2. 02

    ใส่ ชื่อ และ URL ของเอนด์พอยต์ ที่เป็น HTTPS สาธารณะ จากนั้นเลือกอย่างน้อยหนึ่งรายการใน อีเวนต์

  3. 03

    เลือก เพิ่มเอนด์พอยต์ หรือคุณจะสร้างด้วย POST /api/v1/webhooks ก็ได้

Sonny จะปฏิเสธ URL ที่มีข้อมูลเข้าสู่ระบบ, localhost, ช่วง IP แบบส่วนตัวหรือ link-local และชื่อ 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 จะถูกปลุกจากคำตอบของตัวเอง เว้นแต่คุณจะกำหนดเป็นอย่างอื่น มีตัวกรองที่เลือกใช้ได้สามตัวเพื่อจำกัดสิ่งที่เอนด์พอยต์ได้รับ ทั้งหมดปิดไว้เป็นค่าเริ่มต้น เอนด์พอยต์ที่มีอยู่แล้วจึงไม่เปลี่ยนแปลง

agentGroupIds
เฉพาะบทสนทนาที่กลุ่มเจ้าหน้าที่ในรายการเป็นเจ้าของ ตัวกรองนี้อิงตามความเป็นเจ้าของ ไม่ใช่ช่องทางที่บทสนทนาเข้ามา บทสนทนาจากกล่องข้อความที่ใช้ร่วมกันจึงไปถึงกลุ่มที่เป็นเจ้าของ บทสนทนาที่ไม่มีกลุ่มจะไม่ถูกส่ง และเนื่องจากโดยปกติกลุ่มจะถูกกำหนดหลังจากบทสนทนาเริ่มขึ้นแล้ว conversation.created จึงมักเกิดขึ้นก่อนที่จะมีกลุ่มให้จับคู่
customerMessagesOnly
เฉพาะข้อความที่ลูกค้าเขียน ข้ามคำตอบจากทีมของคุณ โน้ตภายใน และทุกอย่างที่ส่งผ่าน API, MCP หรือ AI ตอบกลับอัตโนมัติ รวมถึงคำตอบของเอนด์พอยต์นี้เอง
excludedSenderIds
ข้ามข้อความที่ส่งโดยสมาชิกทีมในรายการ สร้างบัญชีสมาชิกทีมแยกให้การเชื่อมต่อแล้วยกเว้นบัญชีนั้น เพื่อไม่ให้ถูกปลุกจากคำตอบของตัวเอง แต่ยังรับรู้ได้เมื่อมีคนเข้ามารับช่วงบทสนทนาต่อ ซึ่งเป็นสัญญาณที่เอเจนต์ AI ต้องใช้เพื่อถอยออก

ตัวกรองข้อความมีผลกับอีเวนต์ message.* เท่านั้น ส่วนอีเวนต์อื่นๆ ยังควบคุมด้วยประเภทอีเวนต์ตามเดิม อีเวนต์ที่ถูกกรองจะถูกทิ้งก่อนกลายเป็นการส่ง จึงไม่มีค่าใช้จ่ายและไม่แสดงเป็นความล้มเหลว เพย์โหลดและ apiVersion ไม่เปลี่ยนแปลงไม่ว่าในกรณีใด

รวมข้อความที่เข้ามาติดๆ กันเป็นการส่งครั้งเดียว

ระบบที่อ่านทั้งเธรดเมื่อถูกปลุกจะไม่ได้ประโยชน์อะไรจากการส่งสี่ครั้งแยกกันภายในสิบวินาที ตั้งค่า coalesceSeconds แล้วข้อความในบทสนทนาเดียวกันจะถูกรวบรวมไว้ตามระยะเวลานั้น จากนั้นส่งเป็นการส่งครั้งเดียว ตัวเลือกนี้ปิดไว้เป็นค่าเริ่มต้น เอนด์พอยต์ที่ไม่ได้ตั้งค่าจะยังได้รับแต่ละข้อความแยกกันตามเดิม

การส่งแบบรวมกลุ่มจะมาถึงเป็น conversation.activity พร้อมบทสนทนาและ ID ของทุกข้อความที่รวบรวมไว้ ให้อ่านเธรดเพียงครั้งเดียวแทนการอ่านทีละข้อความ นี่คือการตั้งค่าเดียวที่เปลี่ยนรูปแบบของสิ่งที่คุณได้รับ จึงต้องเลือกเปิดใช้เอง ตัวกรองอื่นๆ ของคุณยังมีผลอยู่ ข้อความที่ถูกตัวกรองยกเว้นจะไม่ถูกรวมเข้ากลุ่ม แต่ละบทสนทนามีช่วงเวลาของตัวเอง และการลองส่งใหม่จะถือว่ากลุ่มเป็นการส่งครั้งเดียว

{
  "type": "conversation.activity",
  "data": {
    "conversationId": "cm_conversation_id",
    "messageIds": ["cm_first_message", "cm_second_message"]
  }
}

เอนด์พอยต์ที่ใช้การยืนยันตัวตนแบบ bearer หรือคีย์ API

หากผู้รับของคุณอยู่หลังเกตเวย์ที่ต้องใช้เฮดเดอร์ ให้เพิ่มเฮดเดอร์ใน เฮดเดอร์ที่กำหนดเอง เมื่อสร้างหรือแก้ไขเอนด์พอยต์ หรือส่ง headers ผ่าน API ค่าจะถูกเข้ารหัสขณะจัดเก็บและไม่ถูกส่งกลับมา เฮดเดอร์ที่บันทึกไว้จะส่งกลับมาเพียงชื่อ และการส่งชื่อนั้นซ้ำโดยไม่มีค่าจะคงค่าที่จัดเก็บไว้ ส่งอาร์เรย์ว่างเพื่อนำเฮดเดอร์ทั้งหมดออก

การย้ายเอนด์พอยต์ไปยังโฮสต์อื่นจะยกเลิกการใช้ค่าเดิมซ้ำ ต้องใส่ค่าที่บันทึกไว้ใหม่อีกครั้ง เพื่อไม่ให้ข้อมูลเข้าสู่ระบบถูกส่งต่อไปยังปลายทางที่ไม่ได้ออกให้ การเปลี่ยนเฉพาะพาธจะคงค่าไว้

เฮดเดอร์ของ Sonny มีลำดับความสำคัญเหนือเฮดเดอร์ของคุณ เฮดเดอร์ที่กำหนดเองจึงไม่สามารถแทนที่ sonny-signature, content-type หรือเฮดเดอร์ระบุตัวตนของการส่งได้ หากทำได้ ควรตรวจสอบลายเซ็นเป็นหลัก เพราะลายเซ็นยืนยันความถูกต้องของทุกเพย์โหลด ในขณะที่โทเค็นแบบคงที่ระบุได้เพียงว่าใครเป็นผู้เรียก

{
  "name": "Gateway",
  "url": "https://api.example.com/hooks/sonny",
  "events": ["conversation.created"],
  "headers": [{ "name": "Authorization", "value": "Bearer …" }]
}

ความปลอดภัยและความน่าเชื่อถือ

ลงนามบนเนื้อหาดิบ
HMAC-SHA256 ครอบคลุม Unix timestamp จุดหนึ่งตัว และเนื้อหาคำขอ UTF-8 ที่ไม่ถูกแก้ไข
การป้องกันการส่งซ้ำ
ปฏิเสธ timestamp ที่ห่างจากปัจจุบันเกินห้านาทีทั้งในอดีตหรืออนาคต แม้ว่า HMAC จะถูกต้องก็ตาม
การลองส่งใหม่ที่เชื่อถือได้
การตอบกลับที่ไม่ใช่ 2xx จะลองส่งใหม่หลังจาก 1m, 5m, 30m, 2h, 6h ความพยายามครั้งที่ 6 เป็นครั้งสุดท้าย

ข้อกำหนดของคำขอ

sonny-signature
t=<unix-seconds>,v1=<sha256-hex>
sonny-event
ประเภทอีเวนต์ สำหรับการกำหนดเส้นทางอย่างรวดเร็ว
sonny-delivery-id
ID ที่คงที่ สำหรับ idempotency และการติดต่อฝ่ายสนับสนุน
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

ส่งทุกห้านาทีไปยังผู้สมัครรับที่เปิดใช้งาน (รวมถึง *) แม้ไม่มีข้อความใหม่ ใช้เส้นทางการส่งและการลองส่งใหม่แบบมีลายเซ็นตามปกติ และไม่สนใจตัวกรองบทสนทนา ตั้งการแจ้งเตือนเมื่อสัญญาณ heartbeat หายไปหรือล่าช้า ทั้งนี้อีเวนต์นี้ไม่ได้ตรวจหาบทสนทนาที่รอการตอบกลับ

{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}
contact.created

มีการสร้างผู้ติดต่อ

{"contactId":"cm_contact_id"}
contact.updated

มีการอัปเดตหรือรวมผู้ติดต่อ

{"contactId":"cm_contact_id"}
contact.property_updated

ค่าฟิลด์ของผู้ติดต่อมีการเปลี่ยนแปลง โดยสมาชิกทีม API หรือแบบฟอร์มเป็นผู้ตั้งค่า ส่งเฉพาะเมื่อค่าเปลี่ยนจริง (การเลือกค่าเดิมจะไม่ส่งอะไร) และมีค่าก่อนหน้ารวมอยู่ด้วย โดย null หมายถึงยังไม่ได้ตั้งค่า ค่าที่วิดเจ็ตบนเว็บไซต์กรอกให้ (identify) จะไม่ส่งอีเวนต์นี้ ผู้ติดต่อใหม่ที่เริ่มต้นด้วยค่าเริ่มต้นของฟิลด์แบบตัวเลือกก็จะส่งอีเวนต์นี้เช่นกัน โดยค่าก่อนหน้าเป็น null

{"contactId":"cm_contact_id","propertyId":"cm_property_id","propertyName":"Stage","previousValue":"Prospect","value":"Qualified"}
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"},"contact":{"id":"cm_contact_id","email":"shiney@example.com","phone":"27821234567","name":"Shiney"},"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

ข้อความในบทสนทนาเดียวกันที่รวมกลุ่มเป็นการส่งครั้งเดียว มีสแนปช็อตของแต่ละข้อความเมื่อมีข้อมูล ส่งแทน message.created ให้เอนด์พอยต์ที่ตั้งช่วงเวลารวมกลุ่มไว้ และไม่สามารถสมัครรับโดยตรงได้

{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}
webhook.test

อีเวนต์ทดสอบที่ผู้ดูแลระบบร้องขอ

{"message":"This is a test webhook from Sonny."}

ลักษณะการส่ง

  • ส่งกลับสถานะ 2xx ใดก็ได้ภายใน 10 วินาทีเพื่อระบุว่าการส่งสำเร็จ
  • ทำให้ตัวจัดการเป็นแบบ idempotent ใช้ id ของอีเวนต์หรือ sonny-delivery-id เพื่อข้ามรายการที่ซ้ำ
  • ระบบจะไม่ติดตามการเปลี่ยนเส้นทาง โปรดอัปเดต URL ของเอนด์พอยต์ใน Sonny แทน
  • เนื้อหาการตอบกลับมีขนาดจำกัด และจะเก็บไว้เฉพาะ 4 KiB แรกเพื่อใช้วินิจฉัยปัญหา
  • เลือก ทดสอบ เพื่อส่งอีเวนต์ webhook.test เลือก การส่ง เพื่อดูอีเวนต์ 50 รายการล่าสุด เมื่อการส่งล้มเหลวถึงขั้นสุดท้าย ให้เลือก ลองใหม่ เพื่อส่งอีกครั้ง

ตรวจจับฟีดที่เงียบหายไป

เพิ่ม webhook.heartbeat ในอีเวนต์ของเอนด์พอยต์ในการตั้งค่านักพัฒนา หรือผ่าน update_webhook ผู้สมัครรับที่เปิดใช้งาน (รวมถึง *) จะได้รับสัญญาณ heartbeat ที่มีลายเซ็นทุกห้านาที แม้ไม่มีข้อความใหม่เข้ามา สัญญาณ heartbeat ไม่สนใจตัวกรองช่องทาง ทีม และข้อความ และไม่มีข้อมูลบทสนทนา โดยใช้เส้นทางการส่งและการลองส่งใหม่แบบเดียวกับข้อความ ตรวจสอบ createdAt และ nextExpectedAt เพื่อไม่ให้การลองส่งใหม่ที่ค้างอยู่ดูเหมือนสัญญาณ heartbeat ใหม่ และเผื่อเวลาสำหรับการโพลและความล่าช้าของเครือข่ายก่อนแจ้งเตือน การส่งที่ค้างสะสมอาจทำให้สัญญาณ heartbeat ล่าช้าหรือไม่ถูกส่ง

ใช้ list_webhook_deliveries ร่วมกับ webhooks:read เพื่อดูความพยายามส่งและความล้มเหลว get_status ตรวจสอบการเชื่อมต่อฐานข้อมูล ไม่ใช่การส่งเว็บฮุก ตั้งเวลาเรียก list_conversations แยกต่างหากด้วย status=open, awaitingReply=true, sort=waitingSince และ direction=asc เพื่อหาเธรดเก่าที่ยังไม่ได้ตอบ แม้ในขณะที่ฟีดเงียบอยู่

เปิดใช้เพย์โหลดแบบย่อในการตั้งค่านักพัฒนา หรือตั้ง compact=true บนเอนด์พอยต์ เพื่อตัดป้ายกำกับบริบทออก โดยยังเก็บ ID สำหรับการกำหนดเส้นทาง ผู้ส่ง เวลา และตัวอย่างข้อความ 200 อักขระไว้ ตัวเลือกนี้มีผลกับการส่งแบบรวมกลุ่มด้วย และเนื้อหาโน้ตภายในยังคงไม่ถูกรวม เพย์โหลดเริ่มต้นไม่เปลี่ยนแปลง ยกเว้นมีการเพิ่ม timestamp ของข้อความ แก้ไขขอบเขตสิทธิ์ของคีย์ API ที่มีอยู่เพื่อให้สิทธิ์ webhooks:read และ contacts:read สำหรับบันทึกการส่งและการค้นหาผู้ติดต่อโดยตรง ทั้งสองขอบเขตต้องใช้คีย์ที่เข้าถึงทุกช่องทางและสิทธิ์สมาชิกที่เข้าถึงทุกช่องทาง คีย์ที่จำกัดขอบเขตไว้ไม่สามารถขยายได้ด้วยการเพิ่มสิทธิ์เพียงอย่างเดียว

เอกสารที่เกี่ยวข้อง