Callbacks

แจ้งเตือนเมื่อวิเคราะห์เสร็จ/ไม่สำเร็จ · ลายเซ็น · retry

Callbacks

ให้ระบบของคุณรู้ทันทีที่สายหนึ่งวิเคราะห์เสร็จ (หรือวิเคราะห์ไม่สำเร็จ) — เรา POST ไปที่ URL ของคุณพร้อมลายเซ็นที่ตรวจสอบได้ แทนที่จะต้องวน poll GET /recordings

Account key: การตั้งค่าระดับธุรกิจ (?level=business หรือไม่ส่ง level) ต้องส่ง X-Business-Id: <business id> เหมือน endpoint อื่น ๆ (ถ้าไม่ส่งจะได้ 400 business_required) ส่วน ?level=account ไม่อ่าน header นี้เลย เพราะเป็น endpoint ของพาร์ทเนอร์ ไม่ใช่ของธุรกิจใดธุรกิจหนึ่ง ดูเพิ่มที่ เริ่มต้นใช้งาน → API key ระดับบัญชี

เหตุการณ์ที่ส่ง

eventส่งเมื่อ
analysis_completedสายวิเคราะห์เสร็จสมบูรณ์ — มีบทถอดเสียงและสรุป และหักยอดแล้ว
analysis_failedสายนั้นวิเคราะห์ไม่สำเร็จ และจะไม่มีผลลัพธ์ออกมาเอง

analysis_failed เป็นของใหม่ — ก่อนหน้านี้ไม่มีอะไรบอกว่าสายหนึ่ง "วิเคราะห์ไม่สำเร็จ" เลย ระบบภายนอกจึงเห็นแค่สายที่ค้างอยู่เฉย ๆ โดยไม่รู้ว่าต้องเลิกรอ ถ้าคุณมีคิวที่รอผลอยู่ ให้ปิดงานด้วย event นี้

ทั้งสอง event มี deliveryId ของตัวเอง และระบบส่งแยกกันไปยังแต่ละ endpoint

analysis_failed ของสายเดียวกันมาได้มากกว่าหนึ่งครั้ง — ทุกรอบที่วิเคราะห์ไม่สำเร็จจะส่งมาหนึ่งใบ (เช่น สั่งวิเคราะห์ใหม่แล้วไม่สำเร็จอีก) แต่ละใบมี deliveryId ของตัวเอง และรอบหลังยังจบด้วย analysis_completed ได้ · deliveryId ใช้ dedupe การ retry ของใบเดียว ไม่ใช่ event คนละใบ — ให้เก็บข้อมูลโดยใช้ call.id (หรือเบอร์ผู้โทร call.customerPhoneNormalized) เป็นคีย์ แล้ว upsert ใบที่มาซ้ำจะอัปเดตข้อมูลเดิมแทนที่จะเพิ่มรายการที่สอง

ปลายทางสองระดับ — ยิงทั้งคู่ ไม่ทดแทนกัน

มี endpoint ได้สองระดับ และทั้งสองระดับได้รับ event เดียวกัน

ระดับใครตั้งได้รับอะไร
businessตัวธุรกิจเองเฉพาะสายของธุรกิจนั้น
account (partner)เจ้าของพูลพาร์ทเนอร์สายของทุกธุรกิจในพูล

ถ้าธุรกิจหนึ่งอยู่ใต้พูลพาร์ทเนอร์ และตั้ง callback ของตัวเองไว้ด้วย ระบบจะส่งสายเดียวกัน 2 ครั้ง — ไปที่ endpoint ของธุรกิจ และไปที่ endpoint ของพาร์ทเนอร์ ตัวหนึ่งไม่ได้แทนหรือปิดอีกตัว (ตั้งใจให้เป็นแบบนี้: ธุรกิจลูกค้าที่ตั้ง callback ของตัวเอง ต้องไม่ทำให้ระบบวางบิลที่ตัวแทนจำหน่ายต่อไว้หยุดรับข้อมูล)

  • แต่ละปลายทางมี deliveryId คนละตัว — อย่านับว่าเป็นใบเดียวกัน ถ้าคุณรับทั้งสองฝั่งด้วยระบบเดียว
  • payload ที่ส่งไป endpoint ระดับ account เท่านั้นที่มีบล็อก partner ฝั่ง business จะไม่มีคีย์นี้เลย (ไม่ใช่ null แต่ไม่มีคีย์) — ธุรกิจลูกค้าจึงไม่รู้ว่าตัวเองอยู่ใต้ตัวแทนจำหน่ายรายไหน
  • การ retry ของสองปลายทางแยกจากกัน ปลายทางที่ล่มอยู่ไม่ถ่วงอีกปลายทาง
  • log การส่งก็แยกกัน พาร์ทเนอร์ไม่เห็น log ของ callback ที่ธุรกิจลูกค้าตั้งเอง และธุรกิจก็ไม่เห็นของพาร์ทเนอร์

Payload

มีแค่ metadata กับลิงก์ ไม่มีเนื้อบทสนทนา — ถ้าใส่บทสนทนามาด้วย สายยาวสามชั่วโมงจะกลายเป็น POST ก้อนใหญ่ที่ receiver ส่วนใหญ่ไม่รับ ลิงก์ที่ให้มาคือ API ปกติ ใช้ API key เดิมเรียกเอาเฉพาะส่วนที่ต้องการได้

ฟิลด์ชนิดความหมาย
version1ขึ้นเลขใหม่เฉพาะตอนที่มี breaking change — ฟิลด์ใหม่ที่เพิ่มเข้ามาไม่ขึ้นเลข receiver จึงควรรับคีย์ที่ไม่รู้จักได้โดยไม่ error
eventanalysis_completed | analysis_failedดูตารางด้านบน
deliveryIduuidid ของใบส่งนี้ คงที่ทุกครั้งที่ retry — ใช้ dedupe
occurredAtISO 8601 พร้อม offsetเวลาที่เหตุการณ์เกิด
business.id / business.nameuuid / stringธุรกิจเจ้าของสาย
partner.iduuidมีเฉพาะ payload ที่ส่งไป endpoint ระดับ account
call.iduuidid ของสายในระบบเรา — มีเสมอ ใช้ยิง GET /recordings/{id} ได้แม้ links.* จะเป็น null
call.dataIdstring | nullid ฝั่งคุณที่ส่งมาตอน ingest (ถ้าส่งมา)
call.callAtISO 8601 พร้อม offsetเวลาที่โทร
call.directionin | outสายเข้าหรือสายออก
call.durationMsint | nullความยาวสาย
call.customerPhonestring | nullเบอร์ลูกค้าแบบดิบตามที่เก็บไว้ — ค่าเดียวกับ customerPhone ของ GET /recordings · มีทั้งสอง event
call.customerPhoneNormalizedstring | null (อาจไม่มีคีย์นี้)เบอร์ลูกค้าแบบ normalized (0812345678) — ค่าเดียวกับ customerPhoneNormalized ของ GET /recordings · มีทั้งสอง event ส่งเป็นคีย์สุดท้ายของ call — ดูหัวข้อด้านล่าง
call.callcenterNumberstring | nullเบอร์/คู่สายฝั่งเรา
call.callcenterNamestring | nullชื่อพนักงาน
call.processingStatuscompleted | failedตรงกับ event เสมอ (ระบบใช้ค่าจาก event ไม่ได้อ่านซ้ำจากฐานข้อมูล)
call.scoreint 0-100 | nullคะแนนจาก AI (คะแนนเทเล) — null เสมอบน analysis_failed และบนสายที่ไม่ได้ให้คะแนน
call.customerScorenumber 0-100 | nullคะแนนลูกค้า — ค่าเดียวกับ customerScore ของ GET /recordings · null บน analysis_failed และตอนที่สายยังไม่มีคะแนนลูกค้า — ดูหัวข้อด้านล่าง
call.customerScoreKindsales | service | nullคะแนนลูกค้าประเภทไหน — null ก็ต่อเมื่อ call.customerScore เป็น null
call.customerReadinessScorenumber 0-100 | nullความพร้อมซื้อ ตามหมวดการขาย — ค่าเดียวกับ customerReadinessScore ของ GET /recordings · สายขายเท่ากับ call.customerScore · null เมื่อสายไม่มีอะไรให้วัดในฝั่งนี้ และเมื่อ call.customerScore เป็น null
call.customerSatisfactionScorenumber 0-100 | nullความพึงพอใจ ตามหมวดงานบริการ — กลับด้านของ call.customerReadinessScore (สายบริการเท่ากับ call.customerScore)
call.tagstring | nullแท็กของสาย — null บน analysis_failed
call.flagsstring[] (อาจไม่มีคีย์นี้)key ของ Flag ที่เชื่อถือได้ของสาย เรียงตามตัวอักษร มีเฉพาะ analysis_completed — ดูหัวข้อด้านล่าง
costobject | nullnull บน analysis_failed (ไม่มีการหักเงินสำหรับรอบที่ไม่ได้ผลลัพธ์)
cost.chargedThbnumberยอดบาทที่ถูกหักจริง เป็นตัวเลขเดียวกับที่ GET /recordings คืนมา
cost.currency"THB"สกุลเงิน
links.recordingstring | nullGET /api/v1/recordings/{id}
links.transcriptstring | nullบทสนทนาเต็ม
links.summarystring | nullสรุป
error.messagestringมีเฉพาะ analysis_failed — ดู เหตุผลที่วิเคราะห์ไม่สำเร็จ

เรื่องที่ต้องระวังในการอ่าน payload

  • links.* เป็น null ได้ทั้งสามช่อง ไม่ใช่แค่ transcript/summary — ถ้าระบบยังไม่ได้ตั้ง public origin ไว้ ทั้งสามช่องจะออกมาเป็น null (ดีกว่าส่ง URL ที่ชี้กลับไปยังเครื่องของคุณเอง ซึ่งดูเหมือน URL ที่ถูกต้องแต่ไม่มีทางเรียกได้) call.id มีเสมอ — เขียน receiver ให้ประกอบ URL จาก call.id กับ base URL ที่คุณรู้อยู่แล้ว แล้วถือว่า links.* เป็นของแถม
  • links.transcript / links.summary ยังเป็น null ได้อีกกรณี คือสายนั้นไม่มีบทถอดเสียงหรือสรุปให้ดึง (ปกติคือ analysis_failed)
  • cost.chargedThb เป็น 0 ไม่ได้แปลว่าฟรี — แปลว่า "ยังไม่ถูกหัก" ได้ด้วย อ่านคู่กับ call.processingStatus เสมอ
  • ถ้าธุรกิจอยู่ใต้พาร์ทเนอร์ ยอดนี้ถูกหักจากกระเป๋ารวมของพาร์ทเนอร์ ใช้ตั้งบิลต่อให้ลูกค้าของคุณเองได้

call.flags — Flag ของสาย

call.flags ส่งมาเป็น key ของ Flag เท่านั้น ไม่มีชื่อ ระดับ หรือประโยคหลักฐาน เพราะ payload นี้เป็น metadata — แปล key เป็นชื่อและระดับด้วย GET /recordings/insight-config

  • มีเฉพาะใน analysis_completed · บน analysis_failed จะไม่มีคีย์นี้
  • เรียงตามตัวอักษร (ASCII) และไม่มี key ซ้ำ จึงได้ payload หน้าตาเดิมทุกครั้งสำหรับ Flag ชุดเดิม
  • นับเฉพาะ Flag ที่เชื่อถือได้ กติกาเดียวกับ flags ใน GET /recordings คือ Flag ที่คนเพิ่มเอง หรือ Flag ที่ AI ติดพร้อมประโยคที่หาเจอในสายจริง และยังไม่มีคนกดว่าไม่ถูกต้อง
  • เป็นภาพ ณ ตอนที่วิเคราะห์เสร็จ [] แปลว่าตอนนั้นยังไม่มี Flag ซึ่งรวมถึงตอนที่ธุรกิจปิดการตรวจ Flag ไว้ สายสั้นเกินไป หรือการตรวจยังไม่เสร็จ ถ้า Flag มาเปลี่ยนทีหลังจะไม่มี callback ส่งซ้ำ ให้อ่านค่าล่าสุดจาก flags ของ GET /recordings/{id}
  • receiver ต้องรับมือกับกรณีที่ไม่มีคีย์นี้ได้ — นอกจาก analysis_failed แล้ว คีย์นี้จะหายไปเมื่อระบบอ่าน Flag ไม่ได้ในจังหวะนั้น และ payload ที่ส่งก่อนมีฟีเจอร์นี้ก็ไม่มี

call.customerScore — คะแนนลูกค้า

call.customerScore คือคะแนนลูกค้า (0-100) และ call.customerScoreKind บอกว่าเป็นแบบไหน: sales = ความพร้อมซื้อ · service = ความพึงพอใจของลูกค้าที่โทรเรื่องหลังการขาย/บริการ/บิล ค่าและกติกาเดียวกับ customerScore / customerScoreKind ของ GET /recordings (ดู Recordings → customerScore)

  • อ่านค่า ณ ตอนที่ส่ง callback ซึ่งเป็นจังหวะหลังรอบประเมินคะแนนลูกค้าที่ทำต่อจากการบันทึกผลวิเคราะห์ ถ้ารอบนั้นยังไม่เสร็จทันเวลา (ระบบจะลองต่อเองเบื้องหลัง) หรือธุรกิจปิดการประเมินคะแนนลูกค้าไว้ หรือไม่มีคนรับสาย ทั้งสองช่องจะเป็น null — จะไม่มี callback ส่งซ้ำเมื่อคะแนนมาถึงทีหลัง ให้อ่านค่าล่าสุดจาก GET /recordings/{id}
  • เป็น null เสมอบน analysis_failed เหมือน call.score
  • call.customerScoreKind เป็น null ก็ต่อเมื่อ call.customerScore เป็น null · คะแนนที่ได้ก่อนมีการแยกประเภทจะเป็น sales
  • มีสองคีย์นี้ในทุก payload นับจากนี้ ต่อท้ายฟิลด์อื่นของ call — ฟิลด์เดิมไม่ได้ย้ายที่ · payload ที่ส่งก่อนมีฟีเจอร์นี้ไม่มีสองคีย์นี้ ถ้าไม่มีคีย์ให้ถือว่าเป็น null
  • คะแนนรายหมวดไม่อยู่ใน payload (payload นี้เป็น metadata) ดูได้ที่ summary.customerScoreBreakdown ของ GET /recordings/detail
  • call.customerReadinessScore / call.customerSatisfactionScore คือคะแนนทั้ง 2 ฝั่ง: ทุกสายที่ประเมินแล้วจะได้คะแนนทั้งหมวดการขาย (ความพร้อมซื้อ) และหมวดงานบริการ (ความพึงพอใจ) ในรอบเดียวกัน โดย call.customerScore ยังเป็นคะแนนหลัก คือฝั่งที่ call.customerScoreKind บอก · อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด (แสดงเป็น "—" อย่าแสดงเป็น 0) · ทั้งสองตัวเป็น null เมื่อ call.customerScore เป็น null · คะแนนที่ได้ก่อนมีคะแนน 2 ฝั่งจะอยู่ในฝั่งตามประเภท อีกฝั่งเป็น null · สองคีย์นี้ต่อท้าย call.customerScoreKind · payload ที่ส่งก่อนมีสองคีย์นี้ไม่มี ถ้าไม่มีคีย์ให้ถือว่าเป็น null

call.customerPhoneNormalized — เบอร์ลูกค้า

เพื่อให้ระบบที่ใช้เบอร์ผู้โทรเป็นคีย์ — เช่น สมุดรายชื่อลูกค้าของ CRM — ทำงานต่อได้จาก callback ใบเดียว:

  • มีในทั้งสอง event: ได้เบอร์เสมอ ไม่ว่าการวิเคราะห์จะสำเร็จหรือไม่
  • เป็นรูปแบบ normalized ตรงตามที่เก็บไว้ — ตัวเลขล้วน รหัสประเทศไทยแปลงเป็น 0 นำหน้า (+66 81-234-5678 → 0812345678; เบอร์บ้าน 9 หลักยังเป็น 9 หลัก) — ตรงกับ customerPhoneNormalized ของ GET /recordings ทุกตัวอักษร · call.customerPhone ที่อยู่ข้างกันคือค่าดิบ
  • เป็น null เมื่อสายไม่มีเบอร์ และบนสายเก่าที่เก็บไว้ก่อนมีเบอร์แบบ normalized — ถ้าคุณใช้เบอร์เป็นคีย์ ให้แปลง call.customerPhone เองในกรณีนั้น
  • ถ้าจะนำไปค้นใน endpoint แบบ batch ส่งค่าไปตามที่ได้รับได้เลย: customerPhones / phones แปลงทุกรูปแบบเป็น phone key เดียวกัน (Phones → วิธีจับคู่เบอร์โทร)
  • ส่งเป็นคีย์สุดท้ายของ call ต่อจาก customerSatisfactionScore คีย์เดิมทุกตัวจึงอยู่ที่เดิม · payload ที่ส่งก่อนมีคีย์นี้ไม่มี ถ้าไม่มีคีย์ให้ถือว่าเป็น null

ตัวอย่าง analysis_completed

{
  "version": 1,
  "event": "analysis_completed",
  "deliveryId": "019906b1-3f2a-7c41-9a0e-5b8d21c7ef04",
  "occurredAt": "2026-09-16T14:03:12.412000+07:00",
  "business": { "id": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105", "name": "Home For Cash" },
  "call": {
    "id": "0192b9c4-8e71-7a13-9d02-5f1a6c3b7e90",
    "dataId": "rec_2026_09_16_001",
    "callAt": "2026-09-16T13:41:07+07:00",
    "direction": "in",
    "durationMs": 184320,
    "customerPhone": "+66812345678",
    "callcenterNumber": "+66804972699",
    "callcenterName": "Somchai",
    "processingStatus": "completed",
    "score": 86,
    "tag": "normal",
    "flags": ["churn_risk", "customer_complaint"],
    "customerScore": 72,
    "customerScoreKind": "sales",
    "customerReadinessScore": 72,
    "customerSatisfactionScore": 80,
    "customerPhoneNormalized": "0812345678"
  },
  "cost": { "chargedThb": 2.4132, "currency": "THB" },
  "links": {
    "recording": "https://phone.mcloud.co.th/api/v1/recordings/0192b9c4-8e71-7a13-9d02-5f1a6c3b7e90",
    "transcript": "https://phone.mcloud.co.th/api/recording/transcript/0192b9c4-8e71-7a13-9d02-5f1a6c3b7e90",
    "summary": "https://phone.mcloud.co.th/api/recording/summary/0192b9c4-8e71-7a13-9d02-5f1a6c3b7e90"
  }
}

ตัวอย่าง analysis_failed (ใบที่ส่งเข้า endpoint ระดับ account)

{
  "version": 1,
  "event": "analysis_failed",
  "deliveryId": "019906b2-77c8-7e55-b3aa-9d0c41e8a221",
  "occurredAt": "2026-09-16T14:05:44.198000+07:00",
  "business": { "id": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105", "name": "Home For Cash" },
  "partner": { "id": "01990412-6b8d-7c04-8e19-2a7f5d3c9b64" },
  "call": {
    "id": "0192b9c5-1a04-7b88-8c3d-7e5049216bb2",
    "dataId": "rec_2026_09_16_002",
    "callAt": "2026-09-16T13:58:22+07:00",
    "direction": "out",
    "durationMs": null,
    "customerPhone": "+66898765432",
    "callcenterNumber": null,
    "callcenterName": "Nok",
    "processingStatus": "failed",
    "score": null,
    "tag": null,
    "customerScore": null,
    "customerScoreKind": null,
    "customerReadinessScore": null,
    "customerSatisfactionScore": null,
    "customerPhoneNormalized": "0898765432"
  },
  "cost": null,
  "links": {
    "recording": "https://phone.mcloud.co.th/api/v1/recordings/0192b9c5-1a04-7b88-8c3d-7e5049216bb2",
    "transcript": null,
    "summary": null
  },
  "error": { "message": "audio_unreadable" }
}

Headers

Headerค่า
Content-Typeapplication/json
X-Rexcord-Signaturet=<unix seconds>,v1=<hex> — ดูหัวข้อถัดไป
X-Rexcord-Eventชื่อ event ใช้ route ได้ก่อนแกะ body
X-Rexcord-DeliverydeliveryId ใช้ dedupe ได้โดยไม่ต้องแกะ body

ตรวจสอบลายเซ็น

X-Rexcord-Signature: t=1758000000,v1=9f86d081884c7d659a2feaa0c55ad015...

ลายเซ็นคือ HMAC-SHA256 เป็น hex ตัวพิมพ์เล็ก ของสตริง "<t>.<raw body>" โดยใช้ signing secret (ทั้งสตริงรวม whsec_ ด้วย — คำนำหน้าเป็นส่วนหนึ่งของค่า ไม่ใช่ป้ายที่ต้องตัดทิ้ง) เป็นกุญแจ

กติกาสามข้อที่พลาดกันบ่อยที่สุด — พลาดข้อไหนก็ตรวจไม่ผ่านหรือตรวจไม่ปลอดภัย:

  1. ใช้ raw body เป็น bytes อย่าใช้ JSON ที่ parse แล้ว serialise ใหม่ — ลำดับคีย์และช่องว่างเปลี่ยน ลายเซ็นก็เปลี่ยน (framework ส่วนใหญ่ต้องสั่งเป็นพิเศษถึงจะได้ body ดิบ)
  2. เทียบแบบ constant-time (crypto.timingSafeEqual, hmac.compare_digest) การเทียบสตริงด้วย === รั่วข้อมูลผ่านเวลาที่ใช้เทียบ
  3. ปฏิเสธใบที่เก่าเกินช่วงเวลาที่ยอมรับ แนะนำ 300 วินาที — เราจึงใส่ t ไว้ในสตริงที่เซ็น เพราะถ้าเซ็นแค่ body ใครที่เคยเห็น request ที่ถูกต้องสักใบจะ replay ซ้ำได้ตลอดไป

Node

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.REXCORD_CALLBACK_SECRET // 'whsec_...'
const TOLERANCE_SEC = 300

// express.raw() ไม่ใช่ express.json() — ต้องได้ body ดิบมาคำนวณลายเซ็น
app.post('/rexcord/callback', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get('X-Rexcord-Signature') ?? '').split(',').map((kv) => kv.split('=')),
  )
  const t = Number(parts.t)
  if (!Number.isFinite(t)) return res.sendStatus(400)

  // 3) กัน replay ก่อนเสียเวลาคำนวณ
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return res.sendStatus(400)

  // 1) raw bytes เท่านั้น
  const raw = req.body.toString('utf8')
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.${raw}`)
    .digest('hex')

  // 2) เทียบแบบ constant-time (ความยาวต้องเท่ากันก่อน ไม่งั้น timingSafeEqual โยน error)
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.v1 ?? '', 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)

  const payload = JSON.parse(raw)
  enqueue(payload)          // งานหนักไปทำนอกเทิร์นนี้
  res.sendStatus(200)       // ตอบ 2xx ให้เร็วที่สุด
})

Python

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["REXCORD_CALLBACK_SECRET"]  # 'whsec_...'
TOLERANCE_SEC = 300


@app.post("/rexcord/callback")
def rexcord_callback():
    header = request.headers.get("X-Rexcord-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        abort(400)

    # 3) กัน replay
    if abs(time.time() - t) > TOLERANCE_SEC:
        abort(400)

    # 1) request.get_data() คือ bytes ดิบ — ห้ามใช้ request.json แล้ว dumps ใหม่
    raw = request.get_data()
    expected = hmac.new(
        SECRET.encode("utf-8"),
        f"{t}.".encode("utf-8") + raw,
        hashlib.sha256,
    ).hexdigest()

    # 2) เทียบแบบ constant-time
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401)

    enqueue(request.get_json())
    return "", 200

การส่งซ้ำ (retry)

ครั้งที่เวลา
1ทันที
2+5 วินาที
3+30 วินาที
4+120 วินาที

รวมสูงสุด 4 ครั้ง แล้วหยุด — callback คือการแจ้งเตือน ไม่ใช่คิวงาน ปลายทางที่ยังล่มอยู่หลังจากนั้นควรไปอ่าน GET /recordings ย้อนหลังแทนการรอ POST ใบที่ห้า

  • 4xx ถือว่าจบ ไม่ส่งซ้ำ — ปลายทางปฏิเสธไปแล้วโดยตั้งใจ (รวมถึง 401/403/404/422) ถ้า receiver ของคุณตรวจลายเซ็นไม่ผ่านแล้วตอบ 401 ใบนั้นจะหายไปเลย ระหว่างที่ยังตั้งค่าไม่เสร็จ แนะนำให้ตอบ 500 เพื่อให้มีโอกาสได้รับซ้ำ
  • 429, 5xx และการส่งไม่สำเร็จระดับ transport (DNS, timeout, connection refused) ระบบจะส่งซ้ำตามตาราง
  • timeout ต่อครั้งอยู่ที่ 10 วินาที
  • ตอบ 2xx ให้เร็ว แล้วค่อยไปทำงานหนักแบบ async ถ้า receiver ไปเรียก links.transcript ต่อในเทิร์นเดียวกันแล้วช้าเกิน 10 วินาที ใบนั้นจะนับว่าส่งไม่สำเร็จ และระบบจะส่งซ้ำ ทั้งที่งานของคุณสำเร็จไปแล้ว
  • dedupe ด้วย deliveryId ค่านี้เหมือนเดิมทุกครั้งที่ retry (ต่างจากแถวใน log การส่ง ซึ่งมีแถวละครั้ง) เก็บ deliveryId ที่เคยรับแล้วไว้ แล้วทิ้งใบซ้ำ — จำเป็นจริง เพราะปลายทางที่รับสำเร็จแต่ตอบกลับช้าจนหมดเวลาจะได้รับใบเดิมอีกครั้ง · ครอบคลุมเฉพาะการ retry ของใบเดียว: สอง event ของสายเดียวกัน (analysis_failed กับใบที่ตามมาทีหลัง) คือสองใบ — ให้ upsert ด้วย call.id (ดู เหตุการณ์ที่ส่ง)

เหตุผลที่วิเคราะห์ไม่สำเร็จ

error.message บน analysis_failed เป็นชุดค่าที่ปิดตายแล้ว ไม่ใช่ข้อความ exception ดิบ — เขียน switch บนค่าเหล่านี้ได้เลย และระบบจะไม่ส่งค่าอื่นนอกรายการนี้

error.messageหมายความว่าทำอะไรต่อ
audio_unavailableหาไฟล์เสียงของสายนั้นไม่เจอ (ยังไม่ได้อัปโหลด หรือไฟล์หายจากที่เก็บ)ตรวจว่าอัปโหลดไฟล์สำเร็จจริง แล้วสั่งวิเคราะห์ใหม่
audio_unreadableมีไฟล์ แต่เปิด/ถอดรหัสไม่ได้ (ไฟล์เสีย ตัดมาไม่ครบ หรือว่างเปล่า)ส่งไฟล์ใหม่ที่สมบูรณ์
analysis_timeoutรอบวิเคราะห์ใช้เวลานานเกินกำหนดสั่งใหม่ได้ ถ้าเกิดซ้ำกับสายยาวมากให้แจ้งทีมงาน
storage_failedวิเคราะห์ได้ แต่บันทึกผลไม่สำเร็จสั่งวิเคราะห์ใหม่
analysis_failedไม่สำเร็จด้วยสาเหตุอื่น (ค่าตั้งต้นเมื่อไม่เข้าสี่ข้อบน)สั่งใหม่ได้หนึ่งครั้ง ถ้ายังซ้ำให้แจ้งทีมงานพร้อม call.id

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

ตั้งค่า callback

Scopes: manage_api_tokens

แพ็กเกจ: ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก PUT /callbacks (ระดับธุรกิจ) จะตอบ 403 feature_not_in_package และระบบจะไม่ส่งเหตุการณ์ใดออกไป GET และ DELETE ยังใช้ได้ ปลายทางที่ตั้งไว้แล้วยังเก็บไว้ และจะกลับมาส่งเหมือนเดิมเมื่อแพ็กเกจรวมการส่งผลออก ปลายทางของพาร์ทเนอร์ (?level=account) ตั้งค่าได้ตามปกติ แต่จะไม่ได้รับเหตุการณ์ของธุรกิจนั้นเช่นกัน ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

endpoint เดียวใช้ได้ 3 method โดย ?level= บอกว่าหมายถึงปลายทางระดับไหน

?level=หมายถึงเลือกธุรกิจอย่างไร
ไม่ส่ง หรือ businessendpoint ของธุรกิจbusiness key = ธุรกิจของตัวเอง · account key = ธุรกิจที่ระบุใน X-Business-Id
accountendpoint ระดับพาร์ทเนอร์ของผู้เรียกaccount key เท่านั้น — business key ได้ 403 account_key_required และ X-Business-Id ไม่ถูกอ่านเลย
ค่าอื่น—400 invalid_level (ไม่ถอยไปใช้ business ให้ — ?level=acount ที่พิมพ์ผิดต้องไม่ไปเขียนทับ endpoint ของธุรกิจเงียบ ๆ)

GET /callbacks

อ่านค่าที่ตั้งไว้ และ (ถ้าขอ) log การส่ง

ParamTypeNote
levelbusiness | accountdefault business
deliveries1ใส่เพื่อขอ log การส่งมาด้วย (ถ้าไม่ใส่จะไม่ query ส่วนนี้)
limitint 1-100default 20 — ใช้เมื่อ deliveries=1
offsetintdefault 0
curl "https://phone.mcloud.co.th/api/v1/callbacks?deliveries=1&limit=5" \
  -H "Authorization: Bearer crk_..." \
  -H "X-Business-Id: 0192b7d1-5c30-7a92-9f41-6c2e83b4a105"
{
  "level": "business",
  "businessId": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105",
  "callback": {
    "url": "https://hooks.example.com/rexcord",
    "enabled": true,
    "hasSecret": true,
    "lastAttempt": {
      "status": "ok",
      "httpStatus": 200,
      "error": null,
      "at": "2026-09-16T14:03:12.902Z"
    },
    "createdAt": "2026-09-01T09:12:44.001Z",
    "updatedAt": "2026-09-16T11:20:03.771Z"
  },
  "deliveries": [
    {
      "id": "019906b1-3f2a-7c41-9a0e-5b8d21c7ef04",
      "event": "analysis_completed",
      "attempt": 1,
      "status": "ok",
      "httpStatus": 200,
      "error": null,
      "durationMs": 184,
      "callRecordingId": "0192b9c4-8e71-7a13-9d02-5f1a6c3b7e90",
      "businessId": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105",
      "businessName": null,
      "createdAt": "2026-09-16T14:03:12.902Z"
    }
  ],
  "total": 148
}
  • ถ้ายังไม่เคยตั้งค่า callback จะเป็น null (ไม่ใช่ 404) และ deliveries เป็นรายการว่าง
  • lastAttempt คือผลการส่งครั้งล่าสุด ใช้ตั้ง alert ว่า "ปลายทางของฉันเริ่มตอบ 500" ได้โดยไม่ต้อง poll log ทั้งหมด
  • status มีสองค่า: ok และ failed · httpStatus เป็น null เมื่อไม่เคยได้ response กลับมาเลย (DNS, timeout, ถูกปฏิเสธการเชื่อมต่อ)
  • attempt เริ่มที่ 1 แต่ละครั้งที่ส่งเป็นหนึ่งแถว — แถวเหล่านี้จึงไม่ใช่ตัว dedupe ให้ใช้ deliveryId ใน payload
  • businessName มีค่าเฉพาะบน log ระดับ account (ซึ่งรวมหลายธุรกิจ) บน log ระดับ business จะเป็น null เสมอ เพราะทุกแถวเป็นธุรกิจเดียวกันอยู่แล้ว

PUT /callbacks

สร้างหรือแก้ endpoint

ParamTypeNote
urlstring (สูงสุด 2048)required — ดู กติกาของ URL
enabledbooleanoptional — ถ้าไม่ส่ง ระบบจะคงค่าเดิมไว้ (endpoint ที่เพิ่งสร้างเริ่มต้นเป็น true)
regenerateSecretbooleanoptional — ออก secret ใหม่และยกเลิกอันเดิมทันที
curl -X PUT https://phone.mcloud.co.th/api/v1/callbacks \
  -H "Authorization: Bearer crk_..." \
  -H "X-Business-Id: 0192b7d1-5c30-7a92-9f41-6c2e83b4a105" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/rexcord"}'
{
  "level": "business",
  "businessId": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105",
  "callback": {
    "url": "https://hooks.example.com/rexcord",
    "enabled": true,
    "hasSecret": true,
    "lastAttempt": { "status": null, "httpStatus": null, "error": null, "at": null },
    "createdAt": "2026-09-16T15:02:10.114Z",
    "updatedAt": "2026-09-16T15:02:10.114Z"
  },
  "secret": "whsec_kQ5t8s2Yx1Nf0oR7pW4mJ6dC3aB9vZuLhTgEsKqXcYo"
}

secret คืนมาเฉพาะ response ที่ออก secret นั้น — คือ PUT ครั้งแรก (ที่สร้าง endpoint) และ PUT ที่ส่ง "regenerateSecret": true เท่านั้น response อื่นทั้งหมด รวมถึง GET จะได้แค่ "secret": null กับ hasSecret: true ไม่มีทางอ่านย้อนได้อีก เพราะฝั่งเราเก็บเป็น ciphertext และมีแต่ตัวส่ง callback ที่ถอดได้

ถ้าทำ secret หาย ไม่มีวิธีกู้ ให้ยิง PUT พร้อม "regenerateSecret": true เพื่อออกใหม่ แล้วเอาค่าใหม่ไปใส่ที่ receiver — ระหว่างนั้น ใบที่เซ็นด้วย secret เก่าจะตรวจไม่ผ่านทันที ระหว่าง deploy ให้ receiver รับได้ทั้งสองค่าไว้ชั่วคราว จะกระทบน้อยที่สุด

การแก้เฉพาะ URL ไม่แตะ secret — ถ้าไม่ส่ง regenerateSecret ค่าเดิมยังใช้ได้อยู่

DELETE /callbacks

ลบ endpoint ของระดับนั้น (log การส่งถูกลบตามไปด้วย) ตอบ { "ok": true }

curl -X DELETE "https://phone.mcloud.co.th/api/v1/callbacks?level=account" \
  -H "Authorization: Bearer crk_..."

ถ้าระดับนั้นยังไม่มี endpoint จะได้ 404 callback_not_found ไม่ใช่ 200 เงียบ ๆ — คนที่ลบผิดระดับควรได้รู้

ข้อผิดพลาด

codeHTTPเมื่อไหร่
account_key_required403business key เรียก ?level=account
insufficient_scope403API key ไม่มี scope manage_api_tokens
invalid_level400?level= เป็นค่าอื่นนอกจาก business / account
partner_not_found404?level=account แต่เจ้าของ key ไม่ได้เป็นเจ้าของพูลพาร์ทเนอร์
callback_not_found404DELETE ทั้งที่ระดับนั้นยังไม่มี endpoint
invalid_payload422body ของ PUT ไม่ใช่ JSON object
callback_url_required422url ว่าง
callback_url_too_long422url ยาวเกิน 2048 ตัวอักษร
callback_url_invalid422url ไม่ใช่ URL ที่ parse ได้
callback_url_not_https422ไม่ใช่ https://
callback_url_has_credentials422มี user/password อยู่ใน URL
callback_url_private_host422ชี้ไปโฮสต์ภายใน / loopback / link-local

ระดับธุรกิจที่เรียกด้วย account key ยังได้ 400 business_required และ 403 forbidden_business ตามกติกาเดียวกับ endpoint อื่น

กติกาของ URL

  • https:// เท่านั้น http:// ถูกปฏิเสธ
  • ห้ามใส่ user/password ใน URL (https://user:pass@host/...) ถ้าปลายทางต้องการ auth ให้ใส่ token ไว้ใน path หรือ query แทน แล้วตรวจลายเซ็นเป็นด่านจริง
  • ห้ามชี้เข้าเครือข่ายภายใน — localhost, *.localhost, *.local, *.internal, IPv4 ส่วนตัว/loopback/CGNAT/link-local (10.x, 127.x, 172.16-31.x, 192.168.x, 100.64-127.x, 169.254.x, 0.x, 224.x ขึ้นไป) และ IPv6 loopback / link-local / ULA (::, ::1, fe80::, fc00::/7)
  • ความยาวไม่เกิน 2048 ตัวอักษร
  • ตอนส่งจริง ตัวส่งตรวจซ้ำอีกรอบ รวมถึงค่าที่ DNS resolve ออกมา — ชื่อโฮสต์สาธารณะที่ resolve ไปยังที่อยู่ภายในจะถูกปฏิเสธที่ขั้นตอนส่ง (และไม่ส่งซ้ำ เพราะเป็นค่าที่ผิด ไม่ใช่จังหวะที่ไม่ดี) ส่วนชื่อที่ resolve ไม่ออกเลยนับเป็นการส่งไม่สำเร็จระดับ transport และระบบจะส่งซ้ำตามตาราง

ปลายทางควรเป็นโฮสต์สาธารณะที่มีใบรับรองใช้ได้จริง ถ้าต้องทดสอบจากเครื่องตัวเอง ให้ใช้บริการอุโมงค์ (tunnel) ที่ให้ URL https สาธารณะ แทนการชี้มาที่ localhost