Recordings
หน่วยข้อมูลหลักของระบบ — สายที่บันทึกไว้หนึ่งสาย พร้อมบทถอดเสียงและบทสรุป
Account key: ทุก request ด้านล่างต้องส่ง
X-Business-Id: <business id>เพื่อเลือกธุรกิจที่จะทำงานด้วย (ถ้าไม่ส่งจะได้400 business_required) ส่วน business key จะไม่อ่าน header นี้ เพราะผูกกับธุรกิจเดียวอยู่แล้ว ดูเพิ่มที่ เริ่มต้นใช้งาน → API key ระดับบัญชี
วันที่และเวลา
ทุกพารามิเตอร์ที่เป็นวัน/เวลาในหน้านี้รับ 3 รูปแบบ กติกาเดียวคือ — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (ค่าเริ่มต้น Asia/Bangkok)
| ที่ส่ง | ระบบอ่านเป็น |
|---|---|
2026-05-01 | ทั้งวันตาม tz — ขอบเริ่ม (dateFrom / callAt) = 00:00:00.000, ขอบท้าย (dateTo) = 23:59:59.999 (รวมวันที่ระบุด้วย) |
2026-05-01T08:00:00 | 08:00 น. ตาม tz |
2026-05-01T08:00:00Z | ใช้ตามที่ส่งมาตรงตัว (UTC) |
2026-05-01T08:00:00+07:00 | ใช้ตามที่ส่งมาตรงตัว (offset ที่ระบุ) |
ขอบเขตที่ได้จึงตรงกับ bucket วัน/ชั่วโมงที่ตอบกลับมาเสมอ เพราะอ่านด้วยเขตเวลาเดียวกัน
- ค่าวันที่ผิดรูปแบบหรือไม่มีอยู่จริง (
2026-02-30) จะได้400 validation_failedพร้อมบอกว่าฟิลด์ไหนผิด tzที่ไม่รู้จักจะใช้Asia/Bangkokแทน ไม่ใช่ error- ถ้าใช้ offset แบบ
+07:00ใน query string ควร encode+เป็น%2B(ถ้าลืม ระบบเดาให้ได้ แต่อย่าพึ่งพา) tzมีทั้งใน query string, JSON body (POST /recordings) และ form field (POST /recordings/analyze) — ถ้าไม่อยากพึ่ง default ให้ระบุมาตรง ๆ ได้ทุกที่
GET /recordings
ดูรายการสายแบบแบ่งหน้า
แพ็กเกจ: ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก รายการยังตอบตามปกติ แต่ช่องผลการวิเคราะห์ (
tagscoreคะแนนลูกค้าtranscriptUrlsummaryUrl) เป็นnullและflagsเป็น[]ส่วนการกรองหรือเรียงด้วยช่องเหล่านี้ (tagscoreMin/scoreMaxตัวกรองคะแนนลูกค้าflagflagLevelและsortByที่เป็นคะแนน) จะได้403 feature_not_in_packageดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
limit | int 1-200 | default 20 |
offset | int | default 0 |
direction | "in" | "out" | optional |
sourceProvider | threecx | dtac | upload | minio | script | optional — script = สายจากตัวดึงเสียงที่เพิ่มนอกเหนือจากชนิดในตัว |
sourceSlug | slug ของตัวดึงเสียง | optional — เฉพาะสายที่ดึงเข้ามาด้วยตัวดึงเสียงนี้ (ค่า sourceSlug ของแต่ละแถว ดูรายชื่อ slug ได้จาก GET /providers/catalog) · สายที่เชื่อมมาจากธุรกิจอื่นจะไม่ถูกกรองด้วยค่านี้ และ sourceSlug ของสายเหล่านั้นเป็น null เสมอ |
callDateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
callDateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
tz | IANA timezone | default Asia/Bangkok — ใช้เฉพาะตอน callDateFrom/callDateTo ไม่ได้ระบุ timezone มาเอง |
customerPhone | string (exact match) | optional |
customerPhoneNormalized | string (exact match) | optional |
tag | normal | short | wrong_or_spam | no_answer | other | none | none = ยังไม่ติดแท็ก |
scoreMin | int 0-100 | เฉพาะสายที่มีคะแนนเทเล |
scoreMax | int 0-100 | เฉพาะสายที่มีคะแนนเทเล |
customerScoreMin | int 0-100 | ขอบล่างของ customerScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนลูกค้า ไม่แยกประเภท — ลูกค้าที่สนใจซื้อมากที่สุด = customerScoreKind=sales&customerScoreMin=70 · ถ้าไม่ใส่ customerScoreKind จะได้ทั้ง 2 ประเภท (การขายและงานบริการ) |
customerScoreMax | int 0-100 | ขอบบนของ customerScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนลูกค้า |
customerScoreKind | sales | service | none | เฉพาะสายที่คะแนนลูกค้าเป็นประเภทนี้ — sales = ความพร้อมซื้อ · service = ความพึงพอใจหลังโทรเรื่องบริการ/บิล · ใช้คู่กับ customerScoreMin/customerScoreMax ได้ช่วงคะแนนของประเภทเดียว · none = สายที่ยังไม่มีคะแนนลูกค้า (customerScore: null) ใช้คู่กับ customerScoreMin/customerScoreMax (หรือขอบเขต 4 ตัวด้านล่าง) ไม่ได้ (ได้ 400 validation_failed) |
customerReadinessMin | int 0-100 | ขอบล่างของ customerReadinessScore (ความพร้อมซื้อ, รวมค่านั้น) ทุกประเภทสาย เฉพาะสายที่มีคะแนนฝั่งนี้ · ถ้ามากกว่า customerReadinessMax จะได้ 400 validation_failed |
customerReadinessMax | int 0-100 | ขอบบนของ customerReadinessScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนฝั่งนี้ |
customerSatisfactionMin | int 0-100 | ขอบล่างของ customerSatisfactionScore (ความพึงพอใจ, รวมค่านั้น) ทุกประเภทสาย เฉพาะสายที่มีคะแนนฝั่งนี้ · ถ้ามากกว่า customerSatisfactionMax จะได้ 400 validation_failed |
customerSatisfactionMax | int 0-100 | ขอบบนของ customerSatisfactionScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนฝั่งนี้ — ใช้คู่กับขอบเขตความพร้อมซื้อได้ (ต้องตรงทั้งสองอย่าง) |
search | string | ค้นคำบางส่วน (ไม่สนตัวพิมพ์เล็กใหญ่) ใน dataId, customerPhone, customerPhoneNormalized, callcenterNumber, callcenterName และคำตอบข้อมูลลูกค้าที่เป็นข้อความ (เช่น ชื่อลูกค้าที่ AI ได้จากสาย) — ระบบไม่ค้นคำตอบของสายที่เชื่อมมาจากธุรกิจอื่น |
sortBy | callAt | duration | score | customerScore | customerReadinessScore | customerSatisfactionScore | default callAt |
sortDir | asc | desc | default desc · ค่า null อยู่ท้ายสุด ถ้าเท่ากันจะเรียงตาม callAt desc แล้วตามด้วย id ของสาย |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะสายของพนักงานเหล่านี้ — เทียบกับ key ของพนักงาน (ตัวเดียวกับที่ /recordings/agents คืน) คือ callcenterNumber ของสาย ถ้าสายไม่มีเบอร์จึงใช้ callcenterName เช่น ?agentKey=%2B66804972699&agentKey=Somchai |
flag | string, ส่งซ้ำได้ (สูงสุด 100) | เอาเฉพาะสายที่มี Flag ใด Flag หนึ่งในนี้ ระบุด้วย key ของ Flag เช่น ?flag=churn_risk&flag=customer_complaint — key ทั้งหมดดูได้จาก GET /recordings/insight-config · key ที่รูปแบบผิดได้ 400 validation_failed ส่วน key ที่ธุรกิจไม่ได้ตั้งไว้จะไม่เจอสายไหนเลย |
flagLevel | critical | warning | positive | info, ส่งซ้ำได้ | เอาเฉพาะสายที่มี Flag ระดับนี้อย่างน้อยหนึ่ง Flag — ?flagLevel=critical&flagLevel=warning คือสายที่ควรดู |
ถ้าส่งค่าซ้ำในพารามิเตอร์เดียว ตรงค่าใดค่าหนึ่งก็พอ · ถ้าส่งหลายพารามิเตอร์พร้อมกัน ต้องตรงทุกพารามิเตอร์ ตัวกรอง flag และ flagLevel นับเฉพาะ Flag ชุดเดียวกับที่แสดงใน flags ของแต่ละแถว (ดู flags — Flag ของสาย ด้านล่าง) ทุกแถวที่ได้กลับมาจึงมี Flag ที่ขอไว้ให้เห็นจริง
ทุกการเรียงลำดับปิดท้ายด้วย id ของสาย (ทิศเดียวกับ sortDir) เป็นตัวตัดสินสุดท้าย สายที่เวลาหรือคะแนนเท่ากันจึงเรียงแบบเดิมทุกหน้า — แบ่งหน้าด้วย offset แล้วไม่มีสายหล่นหายหรือซ้ำ · มีผลเฉพาะลำดับของสายที่เสมอกันเป๊ะเท่านั้น
ถ้ารายการยาวเกินกว่าจะใส่ใน URL — เบอร์หลายพันเบอร์ในรูปแบบไหนก็ได้ หรือ id ของสายถึง 1,000 ตัว — ใช้ POST /recordings/search ซึ่งรับตัวกรองชุดเดียวกันใน JSON body
curl "https://phone.mcloud.co.th/api/v1/recordings?limit=10&direction=in&sortBy=score&sortDir=desc" \
-H "Authorization: Bearer crk_..."
สายวันนี้ที่ควรดู:
curl "https://phone.mcloud.co.th/api/v1/recordings?callDateFrom=2026-10-11&callDateTo=2026-10-11&flagLevel=critical&flagLevel=warning" \
-H "Authorization: Bearer crk_..."
{
"items": [
{
"id": "0192b9...",
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T08:14:00+07:00",
"direction": "in",
"customerPhone": "+66812345678",
"customerPhoneNormalized": "0812345678",
"tag": "normal",
"score": 86,
"customerScore": 72,
"customerScoreKind": "sales",
"customerReadinessScore": 72,
"customerSatisfactionScore": 81,
"durationMs": 184320,
"processingTimeMs": 35210,
"processingStatus": "completed",
"audioUrl": "/api/recording/audio/0192b9...",
"transcriptUrl": "/api/recording/transcript/0192b9...",
"summaryUrl": "/api/recording/summary/0192b9...",
"flags": [
{ "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical" }
],
"cost": { "chargedThb": 2.4132, "currency": "THB" }
}
],
"total": 1284,
"limit": 10,
"offset": 0
}
flags — Flag ของสาย
ทุกแถวของ GET /recordings, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชันมี flags เสมอ — Flag ที่ติดอยู่กับสายนั้น เรียงระดับเร่งด่วนขึ้นก่อน (critical → warning → positive → info) แล้วตามลำดับที่ธุรกิจจัดไว้ ถ้าไม่มี Flag จะได้ []
"flags": [
{ "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical" },
{ "key": "callback_promised", "label": "รับปากโทรกลับ", "severity": "info" }
]
keyไม่มีวันเปลี่ยน ใช้เก็บและเทียบในระบบของคุณได้เลย ธุรกิจแก้ชื่อ Flag เมื่อไหร่ key ก็ยังเหมือนเดิม · ส่วนlabelกับseverityอ่านจากการตั้งค่าล่าสุดของธุรกิจทุกครั้ง- ส่งมาเฉพาะ Flag ที่เชื่อถือได้ คือ Flag ที่คนเพิ่มเอง หรือ Flag ที่ AI ติดพร้อมประโยคที่หาเจอในสายจริง Flag ที่ AI หาประโยคยืนยันไม่เจอจะไม่ออกทาง API และ Flag ที่หัวหน้ากดว่าไม่ถูกต้องก็จะหายไปจากที่นี่ด้วย
[]แปลว่าตอนนี้สายนี้ไม่มี Flag ซึ่งรวมถึงสายที่ยังไม่ได้ตรวจ เช่น สายก่อนธุรกิจเปิดใช้ หรือสายที่การตรวจยังไม่เสร็จ- สายที่เชื่อมมาจากธุรกิจอื่น (
linkedFromไม่เป็นnull) ได้flags: []เสมอ เพราะ Flag เป็นของธุรกิจต้นทาง - ประโยคหลักฐานของแต่ละ Flag อยู่ที่
GET /recordings/detail?include=flags· ชื่อและระดับของทุก key อยู่ที่GET /recordings/insight-config
customerScore — คะแนนลูกค้า
ทุกแถวของ GET /recordings, GET /recordings/{id} และ GET /recordings/detail มีคะแนน 2 ตัว:
score— คะแนนเทเล ดูว่าพนักงานดูแลสายได้ดีแค่ไหน (เหมือนเดิม)customerScore— คะแนนลูกค้า 0-100 · ระบบดูก่อนว่าสายนี้เป็นสายแบบไหน แล้วบอกไว้ในcustomerScoreKind:sales— ลูกค้าสนใจ/พร้อมซื้อแค่ไหน ประเมินตามหมวดการขายที่ธุรกิจตั้งเอง (ชุดตั้งต้น: ความต้องการชัดเจน · ความพร้อมด้านงบประมาณ · ช่วงเวลาตัดสินใจ · การตอบรับขั้นตอนถัดไป)service— สายหลังการขาย/แจ้งปัญหา/เรื่องบิล: ลูกค้าพึงพอใจแค่ไหน ประเมินตามหมวดงานบริการของธุรกิจ (ชุดตั้งต้น: ปัญหา/คำถามได้รับการแก้ไข · ความพึงพอใจต่อการบริการ · ความมั่นใจต่อบริการ · ความรวดเร็วและง่าย)
ลูกค้าที่พร้อมซื้อที่สุด และลูกค้าที่พอใจน้อยที่สุด:
curl "https://phone.mcloud.co.th/api/v1/recordings?customerScoreKind=sales&customerScoreMin=70&sortBy=customerScore&sortDir=desc" \
-H "Authorization: Bearer crk_..."
curl "https://phone.mcloud.co.th/api/v1/recordings?customerScoreKind=service&customerScoreMax=39&sortBy=customerScore&sortDir=asc" \
-H "Authorization: Bearer crk_..."
nullแปลว่าสายนี้ไม่มีคะแนนลูกค้า: ไม่มีคนรับสาย (tag: "no_answer") ยังไม่ได้ประเมิน (สายก่อนธุรกิจเริ่มใช้จะไม่ถูกประเมินย้อนหลังเอง) หรือประเมินไม่สำเร็จ · การประเมินทำต่อจากการวิเคราะห์ทันที สายจึงอาจขึ้นprocessingStatus: "completed"ก่อนcustomerScoreมาถึงไม่กี่วินาที ·customerScoreKindเป็นnullก็ต่อเมื่อcustomerScoreเป็นnull- สายที่ยังไม่มีคะแนนลูกค้า:
GET /recordings?customerScoreKind=none— ถ้าใส่customerScoreMin/customerScoreMaxมาด้วยจะได้400 validation_failed - คะแนนที่ให้ไว้ก่อนมีการแยกประเภทจะได้
customerScoreKind: "sales"เพราะตอนนั้นทุกสายประเมินตามหมวดการขาย - คะแนนลูกค้ารายหมวดพร้อมเหตุผล และเหตุผลที่ระบบดูว่าสายนี้เป็น
salesหรือserviceอยู่ที่GET /recordings/detail—summary.customerScoreBreakdownกับsummary.customerScoreKindReason· คะแนนเทเลรายหมวดพร้อมเหตุผลก็อยู่ที่นั่นเช่นกัน —summary.scoreBreakdown - สายที่เชื่อมมาจากธุรกิจอื่นมีคะแนนลูกค้าและประเภทติดมาด้วยเหมือน
score
customerReadinessScore / customerSatisfactionScore — ทุกสายมีคะแนนทั้ง 2 ฝั่ง
ทุกสายที่ประเมินแล้วจะได้คะแนนทั้ง 2 ชุดหมวดในรอบเดียวกัน จึงมีตัวเลข 2 ตัวข้าง customerScore:
customerReadinessScore— ความพร้อมซื้อ 0-100 ตามหมวดการขายcustomerSatisfactionScore— ความพึงพอใจ 0-100 ตามหมวดงานบริการ
customerScore ยังเป็นคะแนนหลักของทั้งสองตัว — คือฝั่งที่ customerScoreKind บอก — สายขาย (sales) จึงมี customerReadinessScore เท่ากับ customerScore และสายบริการ (service) มี customerSatisfactionScore เท่ากับ customerScore · อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัดในฝั่งนั้นเลย (เช่น สายแจ้งปัญหาที่ไม่มีเรื่องซื้อขาย) ให้แสดงเป็น "—" อย่าแสดงเป็น 0 · ทั้งสองตัวเป็น null ก็ต่อเมื่อ customerScore เป็น null · สายที่ประเมินก่อนมีคะแนน 2 ฝั่ง จะมี customerScore อยู่ในฝั่งตามประเภทของสาย ส่วนอีกฝั่งเป็น null
ลูกค้าที่พร้อมซื้อแต่ไม่พอใจ — ทุกประเภทสาย:
curl "https://phone.mcloud.co.th/api/v1/recordings?customerReadinessMin=70&customerSatisfactionMax=39&sortBy=customerReadinessScore&sortDir=desc" \
-H "Authorization: Bearer crk_..."
customerReadinessMin/MaxและcustomerSatisfactionMin/Maxเอาเฉพาะสายที่มีคะแนนฝั่งนั้น · ถ้าขอบล่างมากกว่าขอบบนจะได้400 validation_failed·sortBy=customerReadinessScore/customerSatisfactionScoreเอาสายที่ไม่มีคะแนนฝั่งนั้นไว้ท้ายเสมอทั้งสองทิศ- คะแนนรายหมวดของแต่ละฝั่งอยู่ที่
GET /recordings/detail—summary.customerReadinessBreakdownกับsummary.customerSatisfactionBreakdown
processingTimeMs — เวลาที่ใช้ถอดเสียง
ทุกแถวของ GET /recordings, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชันมี processingTimeMs — เวลาที่ AI ใช้กับสายนั้น หน่วยมิลลิวินาที (ถอดเสียงและวิเคราะห์รวมกัน) ส่วน durationMs คือความยาวของสาย
- นับเฉพาะเวลาที่ AI ทำงาน ไม่รวมเวลารอคิว ก่อนเริ่มถอด
- อ่านจากการวิเคราะห์ครั้งล่าสุดที่สำเร็จ — สายที่สั่งวิเคราะห์ใหม่จะได้เวลาของผลล่าสุด
nullแปลว่ายังไม่มีการวิเคราะห์ที่สำเร็จ: ยังไม่ได้ถอด กำลังถอดเป็นครั้งแรก หรือถอดไม่สำเร็จทุกครั้ง- สายที่เชื่อมมาจากธุรกิจอื่นมีค่านี้ติดมาด้วยเหมือน
score
cost — เครดิตที่สายนี้ใช้ไป
ทุกแถวของ GET /recordings และ GET /recordings/{id} มี cost.chargedThb — ยอดบาทสุทธิที่ถูกหักจากกระเป๋าสำหรับสายนั้น (ทุกยอดที่หักเพราะสายนั้นรวมกัน หักด้วยการคืนเงินถ้ามี — แพ็กเกจที่คิดตามการใช้ AI หักทีละครั้งที่เรียก AI ส่วนแพ็กเกจคิดเป็นนาทีหักครั้งเดียวต่อการวิเคราะห์หนึ่งรอบ ตามความยาวเสียง) ตัวเลขนี้อ่านจากบัญชีเครดิตโดยตรง ไม่ได้คำนวณใหม่ จึงตรงกับยอดที่ถูกหักจริงเสมอ
ถ้าธุรกิจอยู่ใต้พาร์ทเนอร์ กระเป๋าที่ถูกหักคือกระเป๋ารวมของพาร์ทเนอร์ ใช้ตัวเลขนี้ตั้งบิลต่อให้ลูกค้าของคุณเองได้เลย
0 ไม่ได้แปลว่าฟรี — แปลว่า "ยังไม่ถูกหัก" ได้ด้วย เช่นตอน AI ยังไม่ได้รัน (ในแพ็กเกจคิดเป็นนาที สายที่จ่ายด้วยเครดิตทดลองทั้งหมดหรือสั้นกว่า 10 วินาทีก็เป็น 0 เช่นกัน) ให้อ่านคู่กับ processingStatus เสมอ แล้วนับเป็นยอดเรียกเก็บเมื่อ processingStatus เป็น completed แล้วเท่านั้น
POST /recordings/search
GET /recordings ที่ส่งตัวกรองมาใน JSON body — สำหรับรายการที่ใส่ใน URL ไม่ได้ เช่น สายของเบอร์ลูกค้า 10,000 เบอร์จาก CRM ของคุณ หรือ id ของสาย 1,000 ตัว · ตัวกรองทุกตัวของ GET /recordings อยู่ที่นี่ด้วยชื่อเดิมและความหมายเดิม พร้อมตัวกรองแบบรายการเพิ่มเข้ามา · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ
แพ็กเกจ: เหมือน
GET /recordingsถ้าแพ็กเกจไม่รวมการส่งผลออก ช่องผลการวิเคราะห์ (tagscoreคะแนนลูกค้าtranscriptUrlsummaryUrl) เป็นnullและflagsเป็น[]ส่วนตัวกรองหรือsortByที่ใช้ช่องเหล่านี้ (tag/tagsช่วงคะแนนcustomerScoreKindflagflagLevel) จะได้403 feature_not_in_package
Scopes: view_recording_all · view_recording_self
body ใช้กติกาของ Phones → Body ของคำขอแบบ batch และจับคู่เบอร์ด้วย phone key — ส่งรูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร)
| ฟิลด์ | Type | Note |
|---|---|---|
filters | object | optional — ตัวกรองด้านล่าง · ไม่ส่งหรือส่ง null = ไม่กรอง |
sortBy | เหมือน GET /recordings | default callAt |
sortDir | asc | desc | default desc |
limit | int 1-200 | default 50 |
offset | int ≥ 0 | default 0 |
cursor | string | optional — nextCursor ของหน้าก่อน · ใช้ได้เฉพาะ sortBy=callAt และใช้คู่กับ offset ที่มากกว่า 0 ไม่ได้ |
includeTotal | boolean | default true — false = ข้ามการนับ แล้วตอบ total: null |
filters รับตัวกรองค่าเดี่ยวทุกตัวของ GET /recordings ด้วยชื่อเดิมและกติกาเดิม — direction, sourceProvider, sourceSlug, callDateFrom, callDateTo, tz, customerPhone, customerPhoneNormalized, tag, scoreMin, scoreMax, customerScoreMin, customerScoreMax, customerScoreKind, customerReadinessMin, customerReadinessMax, customerSatisfactionMin, customerSatisfactionMax และ search — พร้อมตัวกรองแบบรายการเหล่านี้:
| ตัวกรอง | Type | Note |
|---|---|---|
customerPhones | string[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร) | สายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key |
ids | uuid[] ไม่เกิน 1,000 ค่า | สายเหล่านี้ |
tags | array ของ normal | short | wrong_or_spam | no_answer | other | none | สายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก · ใช้คู่กับ tag ไม่ได้ (400) |
processingStatus | array ของ pending | transcribing | completed | failed | insufficient_credit | สายที่อยู่ในสถานะใดสถานะหนึ่งนี้ |
reviewGrade | string[] (แต่ละค่า 1-30 ตัวอักษร) | สายที่มีเกรดใดเกรดหนึ่งในนี้ — reviewGrade ที่ระบบของคุณเขียนไว้ด้วย PATCH /recordings/{id} เทียบตรงตัว · none = ไม่มีเกรด · เกรดที่ระบุชื่อจะตรงเฉพาะสายของธุรกิจนี้เอง ส่วน none ตรงกับสายที่เชื่อมมาจากธุรกิจอื่นทุกสายด้วย เพราะ reviewGrade ของสายเหล่านั้นเป็น null เสมอ |
agentKey | string[] ไม่เกิน 500 ค่า | agentKey แบบส่งซ้ำของ GET |
callcenterNames | string[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร) | สายที่ชื่อพนักงาน — callcenterName ที่บันทึกไว้กับสาย — ตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร: ตัวพิมพ์เล็ก/ใหญ่ต้องตรง ไม่ตัดช่องว่าง และไม่จับคู่บางส่วน · ต่างจาก agentKey (หนึ่ง key ต่อหนึ่งเบอร์ภายใน) ตรงที่แยกพนักงานสองคนที่ใช้เบอร์ภายในเดียวกันออกจากกันได้ · ค่าที่ใช้ได้ดูจาก GET /recordings/callcenter-names ซึ่งอธิบายกรณีสายที่เชื่อมมาจากธุรกิจอื่นไว้ด้วย |
flag | string[] ไม่เกิน 100 ค่า | flag แบบส่งซ้ำของ GET |
flagLevel | array ของ critical | warning | positive | info | flagLevel แบบส่งซ้ำของ GET |
ค่าในรายการเดียวกัน ตรงค่าใดค่าหนึ่งก็พอ (OR) · ส่วนตัวกรองต่างตัวต้องตรงทุกตัว (AND) — ส่ง agentKey คู่กับ callcenterNames คือ "พนักงานคนนี้ และชื่อนี้" · ไม่ส่ง = ไม่กรอง, [] = ไม่เจอสายไหนเลย, null = 400 · เพดานของ customerPhones, ids และ callcenterNames นับตามจำนวนที่ส่งมา ตัวอื่นนับเฉพาะค่าที่ไม่ซ้ำ · กติกาข้ามฟิลด์ของ GET ยังใช้อยู่ โดย issue จะชี้ไปที่ filters. (เช่น filters.customerScoreKind)
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/search \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"filters": {
"customerPhones": ["0812345678", "+66 89-876-5432"],
"callDateFrom": "2026-10-01",
"tags": ["normal", "none"]
},
"limit": 100,
"includeTotal": false
}'
{
"items": [
{ "id": "0192c1d4-7a20-7b61-9e3f-2d81c5a04b17", "…": "แถวเดียวกับ GET /recordings รวม cost" }
],
"total": null,
"limit": 100,
"offset": 0,
"nextCursor": "eyJjYWxsQXQiOiIyMDI2LTEwLTE0VDA4OjAyOjUxLjQxMjAwMFoiLCJpZCI6IjAxOTJjMWQ0LTdhMjAtN2I2MS05ZTNmLTJkODFjNWEwNGIxNyJ9"
}
itemsคือแถวแบบเดียวกับGET /recordingsทุกประการ — รวมสายที่เชื่อมมาจากธุรกิจอื่นและซ่อนข้อมูลแบบเดียวกันcostคิดจากกระเป๋าของธุรกิจนี้- แบ่งหน้าด้วย offset ใช้ได้กับทุก
sortBy - แบ่งหน้าด้วย cursor ใช้ได้เฉพาะ
sortBy=callAt: ส่งnextCursorของหน้านั้นกลับมาเป็นcursorพร้อมfiltersและsortDirชุดเดิม · แม่นยำแม้มีสายใหม่เข้ามาระหว่าง export ยาว ๆ ซึ่งoffsetอาจทำให้สายหล่นหายหรือซ้ำ · หน้าที่มาด้วยcursorจะตอบoffset: null nextCursorมีให้ทุกหน้าที่sortBy=callAtและยังมีหน้าถัดไป — ไม่ว่าจะมาถึงหน้านั้นด้วยoffsetหรือcursorจึงสลับมาใช้ cursor กลางทางได้ · หน้าสุดท้ายเป็นnullและเป็นnullเสมอเมื่อsortByเป็นค่าอื่น- ให้ส่ง cursor กลับมาตามที่ได้รับ อย่าแก้เอง · cursor ที่ endpoint นี้ไม่ได้ออกให้ได้
400 invalid_cursor· ใช้ cursor กับsortByอื่น หรือคู่กับoffsetที่มากกว่า 0 ได้400 validation_failedที่ชี้ไปที่cursor
อ่านรายการทั้งหมดทีละหน้า:
let cursor = null
do {
const res = await fetch('https://phone.mcloud.co.th/api/v1/recordings/search', {
method: 'POST',
headers: { Authorization: 'Bearer crk_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ filters, limit: 200, includeTotal: false, cursor }),
})
const page = await res.json()
handle(page.items)
cursor = page.nextCursor
} while (cursor)
code | HTTP | เมื่อไหร่ |
|---|---|---|
invalid_payload | 422 | body ไม่ใช่ JSON object |
validation_failed | 400 | คีย์ที่ไม่รู้จัก ค่าเกินขอบเขต ส่ง tag คู่กับ tags ส่ง null ให้ตัวกรองแบบรายการ ผิดกติกาข้ามฟิลด์ของ GET หรือใช้ cursor ในแบบที่ใช้ไม่ได้ |
invalid_cursor | 400 | cursor ไม่ได้ออกโดย endpoint นี้ |
GET /recordings/stats
สถิติรวมของสายตามช่วงเวลา — ยอดรวม จำนวนสายต่อวัน การกระจายคะแนน ชั่วโมงที่สายเข้าหนาแน่น และพนักงานที่มีสายมากที่สุด
แพ็กเกจ: ถ้าแพ็กเกจไม่รวมการส่งผลออก
totals.avgScoretopPerformers[].avgScorescoreDistributionและtagDistributionเป็นnullส่วนตัวเลขจำนวนยังครบ ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
dateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
customerPhoneNormalized | string (exact match) | optional |
tz | IANA timezone | default Asia/Bangkok — ใช้แบ่ง bucket วัน/ชั่วโมง และใช้อ่านค่าวันที่ที่ไม่ได้ระบุ timezone มาเอง |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะสายของพนักงานเหล่านี้ (ความหมายเดียวกับ endpoint ที่ดูรายการสาย) |
curl "https://phone.mcloud.co.th/api/v1/recordings/stats?dateFrom=2026-05-01&dateTo=2026-05-31&tz=Asia/Bangkok" \
-H "Authorization: Bearer crk_..."
{
"totals": {
"total": 1284,
"inbound": 902,
"outbound": 382,
"avgDurationMs": 184320.5,
"longestMs": 1432000,
"avgScore": 78.4,
"firstCallAt": "2026-05-01T08:12:00+07:00",
"lastCallAt": "2026-05-31T19:45:00+07:00"
},
"daily": [{ "day": "2026-05-01", "count": 41 }],
"scoreDistribution": { "good": 612, "mid": 388, "low": 96 },
"tagDistribution": {
"normal": 742,
"short": 96,
"wrong_or_spam": 31,
"no_answer": 161,
"other": 8,
"untagged": 58
},
"peakHours": [{ "hour": 0, "count": 3 }],
"topPerformers": [
{ "name": "Somchai", "calls": 214, "avgDurationMs": 176000, "avgScore": 81.2 }
]
}
scoreDistribution: good ≥ 70, mid 40–69.99, low < 40 (เฉพาะสายที่มีคะแนน)tagDistribution: จำนวนสายต่อแท็กของผู้ตรวจ พร้อมuntaggedสำหรับสายที่ยังไม่ติดแท็ก ทุกคีย์มีเสมอ เลข 0 จึงแปลว่า "ไม่มี" ไม่ใช่ "ไม่ได้ส่งมา" — ใช้ตอบ "มีสายที่ไม่มีคนรับกี่สาย" ได้โดยไม่ต้องไล่ทั้งรายการpeakHours: มีครบ 24 ชั่วโมง (0–23, เติม 0 ให้ชั่วโมงที่ไม่มีสาย)topPerformers: พนักงาน 10 อันดับแรก (ตาม callcenter name) เรียงตามจำนวนสาย
POST /recordings/stats
สถิติชุดเดียวกัน แต่ส่งตัวกรองมาใน JSON body — สำหรับรายการเบอร์ที่ใส่ใน URL ไม่ได้ · response เหมือน GET /recordings/stats ทุกประการ · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ
แพ็กเกจ: เหมือน
GET /recordings/statsและถ้าแพ็กเกจไม่รวมการส่งผลออก การส่งtagsจะได้403 feature_not_in_packageเพราะจำนวนสายของแท็กหนึ่งบอกแท็กของสายได้
Scopes: view_recording_all · view_recording_self
body รับพารามิเตอร์ทุกตัวของ GET ด้วยชื่อเดิม — dateFrom, dateTo, tz, customerPhoneNormalized และ agentKey เป็น array — พร้อมตัวกรองสายอีก 4 ตัว:
| ฟิลด์ | Type | Note |
|---|---|---|
agentKey | string[] ไม่เกิน 500 ค่า | agentKey แบบส่งซ้ำของ GET · เกิน 500 ได้ 400 (ส่วน GET ตัดส่วนเกินทิ้งเงียบ ๆ) |
customerPhones | string[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร) | เฉพาะสายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key — รูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร) |
tags | array ของ normal | short | wrong_or_spam | no_answer | other | none | เฉพาะสายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก |
callcenterNames | string[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร) | เฉพาะสายที่ชื่อพนักงานที่บันทึกไว้กับสายตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร — แบบเดียวกับ POST /recordings/search |
direction | in | out | เฉพาะสายเข้าหรือสายออกตามที่ระบุ |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/stats \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"dateFrom": "2026-10-01",
"dateTo": "2026-10-31",
"customerPhones": ["0812345678", "+66 89-876-5432"],
"tags": ["normal"],
"direction": "in"
}'
- ทุกตัวเลข รวมถึง
topPerformersคำนวณจากสายที่ผ่านตัวกรองแล้ว - body ใช้กติกาของ Phones → Body ของคำขอแบบ batch:
customerPhones: [],tags: [],callcenterNames: []หรือagentKey: []ไม่ตรงกับสายไหนเลย ทุกตัวเลขจึงเป็นศูนย์ · ถ้าไม่ต้องการกรองให้ไม่ส่งคีย์นั้น · คีย์ที่ไม่รู้จักได้400·nullในค่าเดี่ยว (dateFrom,tz,direction, …) ถือว่าไม่ได้ตั้ง
POST /recordings
ส่งข้อมูลสายเข้าระบบ พร้อมขอ presigned URL สำหรับอัปโหลดไฟล์ (ถ้าต้องการ) — ส่ง dataId เดิมซ้ำได้โดยไม่เกิดสายซ้ำ (idempotent)
Scopes: manage_call_recording
| Param | Type | Note |
|---|---|---|
dataId | string unique | required |
callAt | วันที่หรือ ISO 8601 | required — ดู วันที่และเวลา; วันล้วนเช่น 2026-05-20 = 2026-05-20T00:00:00 ตาม tz |
tz | IANA timezone | optional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน (ถ้า callAt มี offset อยู่แล้วช่องนี้ไม่มีผล) |
direction | "in" | "out" | required |
customerPhone | string | optional |
callcenterNumber | string | optional |
callcenterName | string | optional |
tag | enum | optional |
score | number 0-100 | optional |
note | string | optional |
contentType | string (MIME) | optional, default audio/wav — ต้องเป็นชนิดไฟล์เสียง/วิดีโอ (ดูด้านล่าง) |
audioPresignedUploadUrl | boolean | ขอ presigned URL สำหรับอัปโหลดไฟล์หรือไม่ |
contentType รับเฉพาะชนิดไฟล์เสียงและวิดีโอ: audio/mpeg (audio/mp3), audio/wav (audio/x-wav, audio/wave, audio/vnd.wave), audio/ogg, audio/opus, audio/webm, audio/mp4 (audio/m4a, audio/x-m4a), audio/aac, audio/flac, audio/amr, audio/3gpp, audio/aiff, audio/x-ms-wma, video/mp4, video/webm และชื่อเรียกอื่นอีกเล็กน้อย (รายการเต็มคือ enum ของ contentType ใน OpenAPI spec) เทียบแบบไม่สนตัวพิมพ์เล็ก/ใหญ่และดูแค่ชนิดหลัก จึงส่ง audio/webm; codecs=opus ได้ โดยระบบจะเก็บเป็นชนิดหลักล้วน (audio/webm) ถ้าไม่ส่งหรือส่งค่าว่างจะถือเป็น audio/wav ค่าอื่นทั้งหมด เช่น text/html, image/svg+xml, application/octet-stream จะได้ 422 validation_failed พร้อม issues ที่ชี้ไปที่ contentType (ข้อความ unsupported_content_type) และจะไม่มีการบันทึกอะไร
audioUrl จะส่งไฟล์เสียงกลับด้วยชนิดไฟล์นั้นเสมอ (หรือเป็นไฟล์ดาวน์โหลด สำหรับรายการเก่าที่ชนิดที่บันทึกไว้ไม่อยู่ในรายการ) พร้อม X-Content-Type-Options: nosniff — Content-Type ที่คุณส่งตอน PUT ไปที่ presigned URL ไม่มีผลต่อสิ่งนี้
curl -X POST https://phone.mcloud.co.th/api/v1/recordings \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T08:14:00+07:00",
"direction": "in",
"audioPresignedUploadUrl": true
}'
อัปโหลดไฟล์เสียงแล้วเริ่มวิเคราะห์ — เรียก 3 ครั้ง:
POST /recordingsพร้อมaudioPresignedUploadUrl: true→ ได้idของสาย และuploadUrl(ใช้ได้ 15 นาที)PUTไฟล์เสียงไปที่uploadUrlPOST /recordings/{id}/reextract→ ระบบหาไฟล์เสียงที่อัปโหลดไว้เจอ แล้วนำสายเข้าคิว (202) · ถ้ายังไม่ได้เรียกขั้นนี้ สายจะค้างอยู่ที่processingStatus: "pending"และไม่ถูกวิเคราะห์ · ถ้าไฟล์ยังอัปโหลดไม่เสร็จ จะได้409 audio_not_uploadedและไม่มีอะไรเปลี่ยน —PUTให้เสร็จแล้วเรียกใหม่
ธุรกิจที่เก็บไฟล์ไว้ในที่เก็บซึ่งเข้าจากอินเทอร์เน็ตไม่ได้ จะไม่ได้ presigned URL — ส่ง audioPresignedUploadUrl: true แล้วได้ 409 presigned_upload_unavailable และระบบไม่สร้างสาย ให้อัปโหลดไฟล์ผ่าน POST /recordings/analyze แทน
curl -X PUT "$UPLOAD_URL" --data-binary @call.wav
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/$ID/reextract \
-H "Authorization: Bearer crk_..."
GET /recordings/{id}
ดึงข้อมูลสายเดียว
แพ็กเกจ: ถ้าแพ็กเกจไม่รวมการส่งผลออก ยังดูสถานะ ความยาว และยอดเงินของสายได้ แต่ช่องผลการวิเคราะห์ (
tagscoreคะแนนลูกค้าtranscriptUrlsummaryUrl) เป็นnullและflagsเป็น[]
Scopes: view_recording_all · view_recording_self
PATCH /recordings/{id}
ตรวจสาย — คะแนน / แท็ก / ความเห็น / โน้ต / เกรด / การผูกผู้ใช้
Scopes: manage_recording_review · manage_recording_link
| Param | Type | Note |
|---|---|---|
score | number 0-100 | optional |
tag | normal | short | wrong_or_spam | no_answer | other | optional |
reviewComment | string | optional |
note | string | optional |
reviewGrade | string 1-30 | null | optional — เกรดที่ระบบของคุณให้สายนี้ ใช้คำของคุณเอง (เช่น excellent, good, needs_improvement) เก็บตามที่ส่งมา (ตัดช่องว่างหัวท้าย) · null = ล้างเกรด และ reviewedByLabel · ระบบไม่รับ none (ไม่ว่าตัวพิมพ์ไหน) เพราะตัวกรองของ search อ่านค่านี้ว่า "ไม่มีเกรด" |
reviewedByLabel | string 1-100 | null | optional — ผู้ตรวจตามชื่อที่ระบบของคุณใช้ · null = ล้างค่า |
linkedUserId | uuid | null | ต้องมี manage_recording_link |
curl -X PATCH https://phone.mcloud.co.th/api/v1/recordings/$ID \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{"reviewGrade":"good","reviewedByLabel":"CRM admin: Nok"}'
- ส่งเฉพาะสิ่งที่จะเปลี่ยน — ฟิลด์ที่ไม่ส่งจะคงค่าเดิม เช่น แก้แค่คอมเมนต์ เกรดและชื่อผู้ตรวจที่มีอยู่ก็ยังอยู่
- ทุกฟิลด์ยกเว้น
linkedUserIdนับเป็นการตรวจและต้องมีmanage_recording_review· การตรวจทุกครั้ง รวมreviewGradeและreviewedByLabelจะทำเครื่องหมายว่าสายนี้ตรวจแล้วด้วย:reviewStatusเป็นreviewed,reviewedAtเป็นเวลาปัจจุบัน และบันทึกผู้ใช้เจ้าของ API key เป็นผู้ตรวจ reviewStatusเขียนผ่าน API ไม่ได้ — เป็นสถานะของ REXcord ว่า "มีคนตรวจสายนี้แล้ว" ไม่ใช่เกรด · ถ้าส่งมาตัวเดียวจะได้422 empty_patch· ถ้าส่งคู่กับฟิลด์อื่น ระบบจะไม่สนใจค่านี้ · ผลการประเมินของคุณให้ใส่ในreviewGradereviewGrade: nullคู่กับreviewedByLabelที่เป็น string ได้422 validation_failed— การล้างเกรดจะล้างชื่อผู้ตรวจไปด้วย ชื่อที่ส่งมาจึงจะหายไป · ค่าผิดอื่น ๆ (เกรดยาวเกิน 30 ตัวอักษร,none) ก็ได้422 validation_failedเช่นกัน- REXcord ไม่ได้ใช้
reviewGradeหรือreviewedByLabelทำอะไร — เป็นค่าของคุณที่เขียนแล้วอ่านกลับได้ และกรองได้ด้วยreviewGradeในPOST /recordings/search - response คือข้อมูลสายที่อัปเดตแล้ว ไม่มี
cost· สายที่เชื่อมมาจากธุรกิจอื่นตอบ404เหมือนการเขียนทุกแบบ
ทุก endpoint ที่อ่านสาย — GET /recordings, POST /recordings/search, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชัน — มี reviewGrade และ reviewedByLabel ในทุกแถว: เป็น null เมื่อยังไม่มีใครเขียน และเป็น null เสมอบนสายที่เชื่อมมาจากธุรกิจอื่น (การตรวจเป็นของธุรกิจนั้น เหมือน reviewStatus และ note)
DELETE /recordings/{id}
ลบแบบ soft-delete (ย้ายไปถังขยะ เก็บไว้ 30 วัน)
Scopes: manage_call_recording
POST /recordings/{id}/reextract
สั่ง AI ถอดเสียงและสรุปสายนี้ ทำใหม่อีกรอบ หรือทำครั้งแรกหลัง POST /recordings และ PUT ไปที่ presigned URL (ดู อัปโหลดไฟล์เสียงแล้วเริ่มวิเคราะห์ ด้านบน) · คิดเครดิตเหมือนการวิเคราะห์ครั้งอื่น และ callback ของคุณจะทำงานเมื่อเสร็จหรือไม่สำเร็จ
Scopes: manage_call_recording
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/$ID/reextract \
-H "Authorization: Bearer crk_..."
{ "ok": true, "id": "0192c1d4-7a20-7b61-9e3f-2d81c5a04b17", "status": "queued" }
- ไฟล์เสียงของสายต้องอยู่ในที่เก็บไฟล์แล้ว · สายที่สร้างด้วย
POST /recordingsจะยังไม่ถูกบันทึกว่ามีไฟล์เสียงจนกว่า endpoint นี้จะหาเจอ: ระบบจะหาไฟล์ที่อัปโหลดไว้ ถ้ามีไฟล์และไฟล์ไม่ว่าง จะบันทึกว่าสายนี้อัปโหลดแล้วและนำเข้าคิว 202= เข้าคิวแล้ว: สายถูกตั้งกลับเป็นpendingโดยมีไฟล์เสียงพร้อม · ถ้าตอนนั้นติดต่อตัวประมวลผล AI ไม่ได้ สายก็ยังถูกวิเคราะห์ในรอบถัดไปของตัวประมวลผล · ติดตามผลได้จากprocessingStatusของGET /recordings/{id}หรือจาก callback- สายที่เชื่อมมาจากธุรกิจอื่นตอบ
404เหมือนการเขียนทุกแบบ
code | HTTP | เมื่อไร |
|---|---|---|
not_found | 404 | ไม่มีสายนี้ในธุรกิจนี้ |
audio_not_uploaded | 409 | ไม่มีไฟล์เสียงในที่เก็บไฟล์ — PUT ยังไม่เสร็จ ไม่สำเร็จ หรือส่งไปที่ URL ที่หมดอายุแล้ว — หรือไฟล์ว่าง · ไม่มีอะไรเปลี่ยน อัปโหลดแล้วเรียกใหม่ |
storage_unavailable | 503 | ถามที่เก็บไฟล์ไม่ได้ว่ามีไฟล์เสียงหรือยัง · ไม่มีอะไรเปลี่ยน ลองใหม่ |
POST /recordings/analyze
ส่งไฟล์เสียงมาวิเคราะห์แล้วรอรับผลในคำขอเดียว — ระบบเก็บไฟล์ ให้ AI วิเคราะห์ แล้วตอบผลทั้งหมดพร้อมยอดที่คิดเงินแล้ว ระบบจะเก็บไฟล์และแสดงในหน้าเว็บเหมือนสายอื่น ใช้เวลาหลายนาทีตามความยาวสาย ระบบรอผลให้นานสุด 6 ชั่วโมง จึงควรตั้ง client timeout ให้ยาว หรือไม่ตั้งเลย (ดู การรอผล ด้านล่าง)
แพ็กเกจ: ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก endpoint นี้ตอบ
403 feature_not_in_packageก่อนอ่านไฟล์เสียง ให้ส่งสายด้วยPOST /recordingsแทน ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
Scopes: manage_call_recording
body เป็น multipart/form-data (ไม่ใช่ JSON) ส่วน wait ส่งใน query string
| Param | Type | Note |
|---|---|---|
file | file (multipart) | required — ไฟล์เสียงทุกฟอร์แมต (wav/mp3/m4a/mp4/aac/ogg/opus/webm/flac/amr/wma ฯลฯ), ≤500MB — ระบบจะบีบอัดไฟล์ใหญ่หรือไฟล์ที่ไม่ได้บีบอัดเป็น Opus ตอนจัดเก็บ โดยไม่เสียคุณภาพ |
direction | "in" | "out" | optional, default "in" |
customerPhone | string | optional |
callcenterName | string | optional |
callcenterNumber | string | optional |
note | string | optional |
callAt | วันที่หรือ ISO 8601 | optional, ค่าเริ่มต้นคือเวลาปัจจุบัน · ถ้าไม่ระบุ timezone จะอ่านตาม tz — ระบบส่งค่าที่อ่านได้กลับมาในคีย์ callAt ของ response เพื่อให้ตรวจได้ว่าตรงกับที่ตั้งใจ |
tz | IANA timezone | optional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน |
wait | integer (query) | optional, default 21600 (6 ชั่วโมง) — รอผลนานสุดกี่วินาที ใส่จำนวนเต็ม 1–21600 นับตั้งแต่ระบบเก็บไฟล์เสร็จ |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/analyze \
-H "Authorization: Bearer crk_..." \
-H "X-Business-Id: <business uuid, account keys only>" \
-F "[email protected]" \
-F "direction=in" \
-F "customerPhone=+66812345678"
{
"recordingId": "0190f7b2-...",
"dataId": "api-2f1c...",
"callAt": "2026-05-20T01:14:00.000Z",
"status": "completed",
"result": {
"transcript": "แอดมิน: สวัสดีค่ะ...\nลูกค้า: ...",
"segments": [
{ "index": 0, "start_ms": 0, "end_ms": 2400, "speaker": "admin", "text": "สวัสดีค่ะ", "emotion": "neutral", "tone": "polite", "coaching": "" }
],
"summary": "ลูกค้าสอบถามโปรโมชั่น...",
"recommendations": "- [0:50-1:00] ตอนลูกค้าถามเรื่องราคา ควรเสนอ...\n- [2:10-2:25] ช่วงปิดสาย ควรนัดติดตาม...",
"score": 86,
"scoreBreakdown": [
{ "label": "การทักทาย", "weight": 20, "pct": 100, "reason": "แนะนำตัวและบริษัทครบ" },
{ "label": "การนำเสนอ", "weight": 50, "pct": 84, "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" },
{ "label": "การปิดการขาย", "weight": 30, "pct": 80, "reason": "นัดส่งใบเสนอราคาภายในวันนี้" }
],
"customerScore": 72,
"customerScoreKind": "sales",
"customerScoreKindReason": "ลูกค้าโทรมาสอบถามโครงการเพื่อซื้อ",
"customerScoreBreakdown": [
{ "label": "ความต้องการชัดเจน", "weight": 40, "pct": 85, "reason": "ถามราคาและวันเข้าชมโครงการ" },
{ "label": "งบประมาณ", "weight": 30, "pct": null, "reason": "ไม่ได้พูดถึงงบ" },
{ "label": "ความเร่งด่วน", "weight": 30, "pct": 55, "reason": "อยากได้ภายในปีนี้" }
],
"customerReadinessScore": 72,
"customerReadinessBreakdown": [
{ "label": "ความต้องการชัดเจน", "weight": 40, "pct": 85, "reason": "ถามราคาและวันเข้าชมโครงการ" },
{ "label": "งบประมาณ", "weight": 30, "pct": null, "reason": "ไม่ได้พูดถึงงบ" },
{ "label": "ความเร่งด่วน", "weight": 30, "pct": 55, "reason": "อยากได้ภายในปีนี้" }
],
"customerSatisfactionScore": null,
"customerSatisfactionBreakdown": [
{ "label": "ปัญหา/คำถามได้รับการแก้ไข", "weight": 35, "pct": null, "reason": "ไม่ได้แจ้งปัญหา" },
{ "label": "ความพึงพอใจต่อการบริการ", "weight": 30, "pct": null, "reason": "" },
{ "label": "ความมั่นใจต่อบริการ", "weight": 20, "pct": null, "reason": "" },
{ "label": "ความรวดเร็วและง่าย", "weight": 15, "pct": null, "reason": "" }
],
"tag": "normal",
"overallEmotion": "positive",
"overallTone": "friendly",
"insight": {
"flags": [
{ "key": "price_concern", "label": "ลูกค้าติดเรื่องราคา", "severity": "info", "quote": "ราคานี้แพงไปหน่อยนะคะ", "reason": "ลูกค้าบอกว่าราคาสูงกว่างบ", "startMs": 51200 }
],
"customerFields": [
{ "key": "interest", "label": "สิ่งที่สนใจ", "type": "text", "value": "โปรโมชั่นบ้านเดี่ยว", "quote": "สนใจโปรบ้านเดี่ยวค่ะ" },
{ "key": "budget", "label": "งบประมาณ", "type": "number", "value": 3500000, "quote": "งบประมาณสามล้านห้า" }
]
}
},
"cost": { "chargedThb": 0.1234, "currency": "THB" },
"durationMs": 73210
}
cost.chargedThb คือยอดที่คิดเงินกับธุรกิจ (ราคาสุทธิ) ระบบจะไม่เปิดเผยต้นทุนดิบ อัตรา หรือรุ่น AI
result.insight คือ Flag และข้อมูลลูกค้าที่ได้จากสายนี้ ตามรายการที่ธุรกิจตั้งไว้ (ดู GET /recordings/insight-config):
flagsใช้กติกาเดียวกับflagsของสาย คือเฉพาะ Flag ที่เชื่อถือได้ เรียงระดับเร่งด่วนขึ้นก่อน · แต่ละ Flag มีหลักฐานติดมาด้วย:quoteคำพูดในสายที่ Flag นั้นอ้างถึง (nullถ้าเป็น Flag ที่คนใส่เอง หรือตอนนั้นระบบอ่านหลักฐานไม่ได้ — ดูด้านล่าง) ·reasonเหตุผลหนึ่งประโยคว่าทำไมขึ้น Flag นี้ ·startMsตำแหน่งในไฟล์เสียงที่คำพูดนั้นเริ่ม (มิลลิวินาที) — หน้าตาเดียวกับGET /recordings/detail?include=flagscustomerFieldsมีเฉพาะคำถามที่ได้คำตอบ ·typeคือชนิดของคำถาม (text·number·date·choice·yes_no— เป็นnullเฉพาะกรณีที่เกิดน้อยมาก คือระบบอ่านชนิดของคำถามไม่ได้ในตอนนั้น) ·valueเป็นชนิดตามคำถาม: ข้อความ ตัวเลข วันที่YYYY-MM-DDหรือ boolean ·quoteคำพูดในสายที่ได้คำตอบนั้นมา (nullถ้าไม่มี) — หน้าตาเดียวกับGET /recordings/detail?include=customerFieldsต่างกันแค่ไม่มีคำถามที่ไม่ได้คำตอบ- ถ้าธุรกิจปิดการตรวจ Flag ไว้ สายสั้นเกินไป หรือการตรวจใช้เวลานานจนต้องไปทำต่อเบื้องหลัง ทั้งสองรายการจะว่าง — ค่าล่าสุดดูทีหลังได้จาก
GET /recordings/{recordingId} - เป็น
nullเฉพาะตอนที่ผลวิเคราะห์ไม่มีส่วนนี้มาเลย
result.scoreBreakdown คือคะแนนเทเลรายหมวด (score แยกตามหมวด) — รายการเดียวกับที่แท็บ "คะแนน" ในหน้าสายแสดง และเดียวกับ summary.scoreBreakdown ของ GET /recordings/detail · หมวดคือหมวดคะแนนเทเลของธุรกิจ (scoreCategories ของ GET/PUT /recordings/prompt-config): label ชื่อหมวด · weight น้ำหนักของหมวดเป็นเปอร์เซ็นต์ · pct ที่สายนี้ได้ในหมวดนั้น เป็นจำนวนเต็ม 0-100 (ไม่เป็น null) · reason เหตุผลหนึ่งบรรทัดว่าทำไมได้เท่านั้น — รอบประเมินคะแนนลูกค้าที่ทำต่อจากการวิเคราะห์เป็นผู้เขียน จึงเป็น null ถ้ารอบนั้นปิดอยู่ ยังไม่เสร็จทันเวลา หรือเหตุผลไม่ตรงกับหมวดแล้ว และเหตุผลไม่เปลี่ยน score · scoreBreakdown เป็น null ถ้าผลวิเคราะห์ไม่ได้เก็บคะแนนรายหมวดไว้
result.customerScore / result.customerScoreKind คือคะแนนลูกค้าของสายนี้และประเภทของคะแนน — ค่าเดียวกับที่ GET /recordings คืนมา (ดู customerScore ด้านบน) · result.customerScoreKindReason คือเหตุผลหนึ่งบรรทัดที่ระบบดูว่าสายนี้เป็น sales หรือ service ค่าเดียวกับ summary.customerScoreKindReason ของ GET /recordings/detail (null ถ้าไม่มีเหตุผลให้) ส่วน result.customerScoreBreakdown คือคะแนนรายหมวด รายการเดียวกับ summary.customerScoreBreakdown ของ GET /recordings/detail · ระบบอ่านคะแนนทันทีหลังรอบประเมินคะแนนลูกค้าที่ทำต่อจากการวิเคราะห์ (สำหรับ endpoint นี้ รอบนั้นมีเวลาจำกัดสั้น ๆ) ทั้งสี่ช่องเป็น null ถ้าตอนนั้นสายยังไม่มีคะแนนลูกค้า — ไม่มีคนรับสาย ธุรกิจปิดการประเมินคะแนนลูกค้าไว้ ประเมินไม่สำเร็จ หรือใช้เวลานานจนต้องไปทำต่อเบื้องหลัง — ค่าล่าสุดดูทีหลังได้จาก GET /recordings/{recordingId}
result.customerReadinessScore / result.customerSatisfactionScore คือคะแนนลูกค้าทั้ง 2 ฝั่ง (ดู customerReadinessScore / customerSatisfactionScore ด้านบน) พร้อมรายหมวด result.customerReadinessBreakdown / result.customerSatisfactionBreakdown — รายการเดียวกับ summary.customerReadinessBreakdown / summary.customerSatisfactionBreakdown ของ GET /recordings/detail · ฝั่งที่ customerScoreKind บอกเท่ากับ customerScore ส่วนอีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด (รายหมวดยังอาจมีครบทุกหมวดแต่ pct: null แบบตัวอย่างด้านบน) · ทั้งสี่ช่องเป็น null เมื่อ customerScore เป็น null
scoreBreakdown · customerScoreKindReason · quote / reason / startMs ของ Flag และ type / quote ของข้อมูลลูกค้า ระบบอ่านก่อนส่งผลไม่นาน และจะไม่ทำให้ผลมาช้าเกิน wait ถ้าอ่านไม่ทัน (ฐานข้อมูลช้าหรือขัดข้องชั่วคราว) ช่องเหล่านี้จะเป็น null ส่วนอื่นยังส่งมาครบตามปกติ — null ในช่องเหล่านี้จึงแปลได้อีกอย่างว่า "ตอนนั้นอ่านไม่ได้" · ถ้าต้องการค่าที่แน่นอน ให้อ่านสายนี้ซ้ำจาก GET /recordings/detail?id=<recordingId>&include=summary,flags,customerFields
การรอผล (?wait=)
พอระบบเก็บไฟล์เสร็จ ระบบจะเริ่มตอบทันทีทั้งที่ยังไม่มีผล:
- ส่ง status
200พร้อม headerX-Recording-Id,X-Data-IdและContent-Type: application/json - ระหว่างวิเคราะห์ ส่ง ช่องว่าง 1 ตัวทุก 15 วินาที ให้การเชื่อมต่อไม่หลุด
- วิเคราะห์เสร็จ (หรือครบเวลา
wait) แล้วจึงส่ง JSON ตามมา
ช่องว่างหน้า JSON ถูกต้องตามมาตรฐาน JSON.parse, res.json(), requests.json() และ Postman จึงอ่านได้ตามปกติ
ถ้าไม่ใส่ wait ระบบจะรอนานสุด 6 ชั่วโมง ถ้าอยากให้เลิกรอเร็วกว่านั้น ใส่ ?wait=<วินาที> (1–21600) เช่น รอแค่ 10 นาที:
curl -X POST "https://phone.mcloud.co.th/api/v1/recordings/analyze?wait=600" \
-H "Authorization: Bearer crk_..." \
-F "[email protected]"
waitเริ่มนับหลังระบบเก็บไฟล์เสร็จ ให้ตั้ง timeout ฝั่ง client ยาวกว่าwaitเล็กน้อย (เผื่อเวลาอัปโหลด) หรือไม่ตั้งเลย — ใน Postman ไปที่ Settings → General → Request timeout แล้วใส่0· ครบเวลาwaitแล้วระบบตอบextraction_timeoutให้เอง- ถ้าการเชื่อมต่อหลุดกลางทาง (เน็ตหลุด หรือระบบของเราอัปเดตหลายรอบระหว่างที่รอนานมาก) การวิเคราะห์ยังทำต่อจนเสร็จ ใช้
X-Recording-Idที่ได้ไปแล้วดึงผลด้วยGET /recordings/{id}ไม่ต้องส่งไฟล์ซ้ำ (ถ้าส่งซ้ำ ระบบจะวิเคราะห์และคิดเงินอีกรอบ)
ข้อผิดพลาด
ข้อผิดพลาดมี 2 แบบ ขึ้นกับว่าเกิดก่อนหรือหลังระบบเก็บไฟล์เสร็จ
ก่อนเก็บไฟล์ — ตอบเป็น HTTP status จริง ระบบยังไม่ได้บันทึกอะไร แก้แล้วส่งใหม่ได้เลย
| Code | Status | ความหมาย |
|---|---|---|
missing_token · invalid_token · token_disabled | 401 | ปัญหาที่ตัว API key |
insufficient_scope | 403 | API key ไม่มี scope manage_call_recording |
business_required | 400 | account key ที่ไม่ได้ส่ง X-Business-Id |
forbidden_business | 403 | X-Business-Id ผิดรูปแบบ หรือเจ้าของ key ไม่ได้เป็นสมาชิกของธุรกิจนั้น |
expected_form_data | 415 | body ไม่ใช่ multipart/form-data |
audio_file_too_large | 413 | ไฟล์ใหญ่เกิน 500MB |
audio_unsupported_format | 415 | ไม่ใช่ไฟล์เสียงที่รองรับ |
audio_file_required | 422 | ไม่ได้แนบ file |
invalid_call_at | 422 | อ่านค่า callAt ไม่ได้ |
invalid_wait | 422 | wait ไม่ใช่จำนวนเต็ม 1–21600 |
worker_unavailable | 503 | ระบบวิเคราะห์ยังไม่พร้อม ลองใหม่ภายหลัง |
storage_unavailable | 503 | บันทึกไฟล์ไม่สำเร็จในตอนนี้ ระบบไม่ได้เก็บอะไรไว้ ลองใหม่ภายหลัง |
หลังเก็บไฟล์ — ระบบส่ง status 200 ไปแล้ว ข้อผิดพลาดจึงมาใน body พร้อม recordingId / dataId ของสายที่บันทึกไว้ ส่วน error.status คือ HTTP status ที่ตรงกับข้อผิดพลาดนั้น:
{ "error": { "code": "extraction_timeout", "status": 504 }, "recordingId": "0190f7b2-...", "dataId": "api-2f1c..." }
error.code | error.status | ความหมาย |
|---|---|---|
insufficient_credit | 402 | เครดิตของธุรกิจไม่พอสำหรับการวิเคราะห์ |
extraction_failed | 502 | วิเคราะห์ไม่สำเร็จ |
extraction_timeout | 504 | ครบเวลา wait แล้วยังไม่เสร็จ — การวิเคราะห์ยังทำต่อจนเสร็จ ดึงผลทีหลังด้วย GET /recordings/{recordingId} |
จึงต้องเช็กคีย์ error ใน body ทุกครั้ง ไม่ใช่ดูแค่ HTTP status:
const res = await fetch(url, { method: 'POST', headers, body: form })
const body = await res.json()
if (!res.ok || body.error) {
// body.error.code บอกสาเหตุ · body.recordingId (ถ้ามี) คือสายที่บันทึกไว้แล้ว
}
GET /recordings/agents
รายชื่อพนักงานรับสาย — รวมพนักงานที่พบในสาย (มี calls / lastCallAt จริง) เข้ากับ พนักงานที่ซิงก์มาจาก provider แต่ยังไม่มีสาย (จะได้ calls: 0 กับ lastCallAt: null) พนักงานที่เพิ่งดึงจาก provider จึงขึ้นในรายการได้ก่อนมีสายแรก นำ key ที่ได้ไปใช้เป็นตัวกรอง agentKey กับ endpoint รายการสายและสถิติได้เลย (key คือ callcenterNumber ของพนักงาน ถ้าไม่มีเบอร์จึงใช้ callcenterName)
Scopes: view_recording_all · view_recording_self (API key ที่มีแค่ view_recording_self เห็นเฉพาะพนักงานที่ผูกกับผู้ใช้เจ้าของ key)
curl https://phone.mcloud.co.th/api/v1/recordings/agents \
-H "Authorization: Bearer crk_..."
{
"items": [
{
"key": "+66804972699",
"name": "66804972699 FIN4YOU 07",
"number": "+66804972699",
"calls": 412,
"lastCallAt": "2026-06-09T14:13:53.000Z"
},
{
"key": "12128",
"name": "New Agent",
"number": "12128",
"calls": 0,
"lastCallAt": null
}
],
"total": 8
}
GET /recordings/callcenter-names
ชื่อพนักงานทุกชื่อที่บันทึกไว้กับสายที่คุณอ่านได้ — คือค่าที่ตัวกรอง callcenterNames รับ (ใน POST /recordings/search และ /recordings/stats, /insights/overview|agents|timeseries แบบ POST) · GET /recordings/agents เก็บชื่อเดียวต่อหนึ่ง key พนักงานสองคนที่เคยใช้เบอร์ภายในเดียวกันจึงกลายเป็นพนักงานคนเดียวที่นั่น ส่วนที่นี่เป็นสองชื่อ แต่ละชื่อมีจำนวนสายของตัวเอง
Scopes: view_recording_all · view_recording_self (API key ที่มีแค่ view_recording_self เห็นเฉพาะชื่อบนสายของตัวเอง)
| Param | Type | Note |
|---|---|---|
dateFrom | วันที่หรือ ISO 8601 | optional — เฉพาะสายตั้งแต่เวลานี้ (callAt) ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — วันล้วน = ถึงสิ้นวันนั้น (รวมวันนั้นด้วย) |
tz | IANA timezone | default Asia/Bangkok — ใช้อ่านวันที่ที่ไม่ได้ระบุเขตเวลา |
ถ้าไม่ส่งทั้ง dateFrom และ dateTo จะนับทุกสายที่มี
curl "https://phone.mcloud.co.th/api/v1/recordings/callcenter-names?dateFrom=2026-10-01&dateTo=2026-10-31" \
-H "Authorization: Bearer crk_..."
{
"items": [
{ "name": "Aom", "calls": 37, "lastCallAt": "2026-10-15T09:12:40.000Z" },
{ "name": "Bee", "calls": 112, "lastCallAt": "2026-10-16T03:41:07.000Z" }
],
"total": 2
}
- เรียงตาม
name· สายที่ไม่มีชื่อหรือชื่อว่างไม่นับ · ส่งnameกลับไปตามที่ได้ทุกตัวอักษร — ตัวกรองเทียบตรงตัว (ตัวพิมพ์เล็ก/ใหญ่ต้องตรง ไม่ตัดช่องว่าง) callsเท่ากับจำนวนสายที่callcenterNames: [name]คืนในช่วงเวลาเดียวกันพอดี และlastCallAtคือสายล่าสุดในนั้น- สายที่เชื่อมมาจากธุรกิจอื่น มีชื่อที่ธุรกิจนั้นบันทึกไว้ ชื่อจึงทำงานแบบเดียวกับ
agentKeyแบบธรรมดา: ชื่อที่ธุรกิจนี้เคยบันทึกไว้กับสายของตัวเองแม้แต่สายเดียว หมายถึงพนักงานของธุรกิจนี้ — นับ และตัวกรองจับคู่ เฉพาะสายของธุรกิจนี้เอง ไม่รวมคนชื่อซ้ำของธุรกิจที่เชื่อมมา · ชื่อที่มีอยู่แค่บนสายที่เชื่อมมา จะนับและจับคู่สายที่เชื่อมมาเหล่านั้น
POST /recordings/agents/sync
ดึงรายชื่อพนักงานทั้งหมดจากทุกการเชื่อมต่อของธุรกิจที่ตัวดึงเสียงมีรายชื่อ (รายชื่อเบอร์ภายในของ 3CX / รายชื่อผู้ใช้ของ DTAC / ตัวดึงเสียงใดก็ได้ที่ GET /providers/catalog บอก features.people) เข้ารายชื่อพนักงานของระบบ — รวมการเชื่อมต่อที่ปิดรอบดึงไว้ด้วย หลังจากนี้ GET /recordings/agents จะคืนพนักงานครบทุกคน รวมถึงคนที่ยังไม่มีสาย ทำงานเหมือนปุ่ม "ดึงจาก provider" ในหน้าทีมของแอป เป็นการ เรียกสดไปที่ระบบปลายทาง (login + list) จึงอาจใช้เวลาสองสามวินาที
Scopes: manage_recording_link
| Field | ความหมาย |
|---|---|
synced | จำนวนพนักงานที่เพิ่มหรืออัปเดตในรายชื่อ |
providers | จำนวนการเชื่อมต่อที่ถูกขอรายชื่อ — 0 = ไม่มีการเชื่อมต่อที่มีรายชื่อ |
failed | slug ของตัวดึงเสียงที่การเชื่อมต่อตอบไม่สำเร็จ (เช่น ["dtac"]) — ไม่สำเร็จแค่บางส่วน ส่วนที่เหลือยังทำงานตามปกติ |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/agents/sync \
-H "Authorization: Bearer crk_..."
{ "synced": 24, "providers": 1, "failed": [] }
PATCH /recordings/agents/{extension}
ตั้งหรือล้างชื่อที่แสดง (label) ของพนักงาน — เหมือนการแก้ชื่อบนการ์ดในหน้าทีม โดย {extension} คือ number ของพนักงานที่ได้จาก endpoint รายชื่อพนักงาน (ต้อง URL-encode เช่น +66804972699 → %2B66804972699) ชื่อใหม่จะมีผลกับทุกสายที่มี callcenterNumber เดียวกัน รวมถึงสายเก่า ทำให้ leaderboard รายการสาย และส่วนหัวของ player แสดงชื่อใหม่ทันที
ส่ง customName: null เพื่อล้างชื่อที่ตั้งเอง แล้วกลับไปใช้ชื่อจาก provider
Scope: manage_recording_link
| field | type | notes |
|---|---|---|
customName | string (1–128) | null, required | ชื่อใหม่ — ส่ง null เพื่อล้างชื่อที่ตั้งเอง |
curl -X PATCH "https://phone.mcloud.co.th/api/v1/recordings/agents/%2B66804972699" \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{"customName":"ทีมขาย A"}'
{
"ok": true,
"extension": "+66804972699",
"customName": "ทีมขาย A",
"effectiveName": "ทีมขาย A"
}
จะคืน 404 extension_not_found เมื่อไม่พบเบอร์ภายในนี้ในธุรกิจ
GET /recordings/prompt-config
อ่าน config การวิเคราะห์สายด้วย AI ของธุรกิจ — บริบทธุรกิจที่ AI ใช้เป็น persona คำอธิบายแท็กทั้ง 5 ประเภท เกณฑ์การให้คะแนนแบบถ่วงน้ำหนัก ความยาวของผลลัพธ์ (summaryStyle / coachingStyle กับคำสั่งเพิ่มเติมของธุรกิจ) เกณฑ์สายสั้น (shortCallMaxSec) และหมวดคะแนนลูกค้า (customerScore — มีเฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่) ซึ่งเป็น config เดียวกับที่หน้า /app/prompt ในแอปใช้จัดการ และ AI จะนำไปประกอบเป็น persona ทุกครั้งที่ถอดเสียงหรือวิเคราะห์สาย · ธุรกิจที่ยังไม่เคยตั้งค่าจะได้ค่าเริ่มต้น (คำอธิบายแท็ก เกณฑ์รวม 100 คะแนน และความยาว standard) โดย businessName จะใช้ชื่อธุรกิจแทน
Scopes: manage_recording_prompt · view_recording_all
curl "https://phone.mcloud.co.th/api/v1/recordings/prompt-config" \
-H "Authorization: Bearer crk_..."
{
"businessName": "Acme Co.",
"businessAbout": "ขายอสังหาริมทรัพย์ ลูกค้าเป็นกลุ่มครอบครัว",
"businessProducts": "บ้านเดี่ยว, ทาวน์โฮม, คอนโด",
"tagDescriptions": {
"normal": "บทสนทนาปกติ มีเนื้อหา/ติดต่อธุรกิจจริง",
"short": "สายสั้นมาก ไม่มีเนื้อหา หรือวางสายก่อนพูดจบ",
"wrong_or_spam": "โทรผิด สายขายของ spam สแกม หรือไม่เกี่ยวกับธุรกิจ",
"no_answer": "สายที่ไม่มีคนรับ ไม่มีบทสนทนา (missed call)",
"other": "อื่นๆ ที่ไม่เข้ากลุ่มข้างบน"
},
"scoreCategories": [
{ "label": "การทักทายและมารยาท", "maxPoints": 20 },
{ "label": "การสอบถามข้อมูลครบถ้วน", "maxPoints": 30 },
{ "label": "การจัดการข้อโต้แย้ง", "maxPoints": 20 },
{ "label": "การปิดการสนทนา/นัดหมาย", "maxPoints": 30 }
],
"summaryStyle": "short",
"coachingStyle": "standard",
"summaryInstructions": "บอกด้วยว่าลูกค้าสนใจสินค้าตัวไหน",
"coachingInstructions": "",
"shortCallMaxSec": null,
"shortCallMaxSecDefault": 30,
"shortCallMaxSecEffective": 30,
"customerScore": {
"categories": [
{ "label": "ความต้องการชัดเจน", "maxPoints": 40 },
{ "label": "งบประมาณ", "maxPoints": 30 },
{ "label": "นัดขั้นตอนถัดไป", "maxPoints": 30 }
],
"serviceCategories": [
{ "label": "ปัญหา/คำถามได้รับการแก้ไข", "maxPoints": 35, "hint": "ปัญหาหรือคำถามของลูกค้าได้คำตอบหรือทางแก้ที่ชัดเจนในสายนี้" },
{ "label": "ความพึงพอใจต่อการบริการ", "maxPoints": 30, "hint": "ลูกค้าพูดหรือแสดงออกว่าพอใจ ขอบคุณ ไม่บ่นหรือหงุดหงิด" },
{ "label": "ความมั่นใจต่อบริการ", "maxPoints": 20, "hint": "ลูกค้ามั่นใจว่าจะได้รับการดูแลต่อ ไม่พูดถึงการยกเลิกหรือย้ายไปที่อื่น" },
{ "label": "ความรวดเร็วและง่าย", "maxPoints": 15, "hint": "ได้สิ่งที่ต้องการโดยไม่ต้องรอนาน ไม่ถูกโอนหลายต่อ ไม่ต้องติดต่อซ้ำ" }
],
"isDefault": false,
"isServiceDefault": true,
"defaultCategories": [
{ "label": "ความต้องการชัดเจน", "maxPoints": 30, "hint": "ลูกค้าบอกได้ว่าอยากได้อะไร แบบไหน ใช้ทำอะไร" },
{ "label": "ความพร้อมด้านงบประมาณ", "maxPoints": 25, "hint": "พูดถึงงบ ราคาที่รับได้ วิธีจ่าย" },
{ "label": "ช่วงเวลาตัดสินใจ", "maxPoints": 20, "hint": "จะซื้อ/ตัดสินใจเมื่อไร มีกำหนดชัดไหม" },
{ "label": "การตอบรับขั้นตอนถัดไป", "maxPoints": 25, "hint": "รับนัด ขอใบเสนอราคา ส่งเอกสาร ให้ช่องทางติดต่อ" }
],
"defaultServiceCategories": [
{ "label": "ปัญหา/คำถามได้รับการแก้ไข", "maxPoints": 35, "hint": "ปัญหาหรือคำถามของลูกค้าได้คำตอบหรือทางแก้ที่ชัดเจนในสายนี้" },
{ "label": "ความพึงพอใจต่อการบริการ", "maxPoints": 30, "hint": "ลูกค้าพูดหรือแสดงออกว่าพอใจ ขอบคุณ ไม่บ่นหรือหงุดหงิด" },
{ "label": "ความมั่นใจต่อบริการ", "maxPoints": 20, "hint": "ลูกค้ามั่นใจว่าจะได้รับการดูแลต่อ ไม่พูดถึงการยกเลิกหรือย้ายไปที่อื่น" },
{ "label": "ความรวดเร็วและง่าย", "maxPoints": 15, "hint": "ได้สิ่งที่ต้องการโดยไม่ต้องรอนาน ไม่ถูกโอนหลายต่อ ไม่ต้องติดต่อซ้ำ" }
]
},
"updatedAt": "2026-06-10T04:54:12.745Z",
"updatedBy": null
}
shortCallMaxSec— เกณฑ์สายสั้นของธุรกิจเอง หน่วยวินาที (null= ใช้ค่ากลางของระบบ) สายที่สั้นกว่าเกณฑ์จะได้แท็กshortและไม่นับในค่าเฉลี่ยคะแนน ·shortCallMaxSecDefaultคือค่ากลางของระบบ ส่วนshortCallMaxSecEffectiveคือค่าที่ใช้อยู่จริง ทั้งสองอ่านได้อย่างเดียว
customerScore — หมวดคะแนนลูกค้า
หมวดที่ใช้ให้คะแนนลูกค้า (customerScore ของแต่ละสาย) เป็นชุดเดียวกับที่ตั้งในส่วนคะแนนลูกค้าของหน้า /app/prompt มี 2 ชุด:
categories— ชุดการขาย ใช้ดูว่าลูกค้าสนใจ/พร้อมซื้อแค่ไหน (สายที่ได้customerScoreKind: "sales")serviceCategories— ชุดงานบริการ ใช้ดูว่าลูกค้าที่โทรเรื่องหลังการขาย แจ้งปัญหา หรือเรื่องบิล พึงพอใจแค่ไหน (สายที่ได้customerScoreKind: "service")- แต่ละหมวดคือ
{ label, maxPoints, hint? }·maxPointsคือน้ำหนักเป็นเปอร์เซ็นต์ รวมทั้งชุดต้องได้ 100 พอดี ·hint(สิ่งที่ระบบมองหาตอนให้คะแนน) มีเฉพาะในชุดตั้งต้นของระบบ isDefault/isServiceDefaultเป็นtrueเมื่อธุรกิจยังไม่ได้ตั้งชุดนั้นเอง ชุดที่เห็นคือชุดตั้งต้นของระบบ และจะเปลี่ยนตามเมื่อระบบปรับชุดตั้งต้น ·defaultCategories/defaultServiceCategoriesคือชุดตั้งต้นของระบบ ณ ตอนนี้- การแก้หมวดมีผลกับสายที่ประเมินหลังจากนั้น คะแนนที่ให้ไปแล้วจะไม่ถูกคิดใหม่
customerScoreจะมีเฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่เท่านั้น ถ้าปิดอยู่จะไม่มีคีย์นี้ และอาจหายไปชั่วคราวเมื่ออ่านไม่สำเร็จ (ส่วนอื่นของ config ยังส่งกลับมาครบ) ให้อ่านใหม่
PUT /recordings/prompt-config
บันทึกหรืออัปเดต config การวิเคราะห์ด้วย AI (ธุรกิจละ 1 ชุด) · ช่องบริบทธุรกิจที่เว้นว่างจะเก็บเป็น null และ businessName จะใช้ชื่อธุรกิจแทน · scoreCategories มีได้ 1–20 หมวด · endpoint ตรวจรูปแบบข้อมูลแต่ไม่บังคับให้ผลรวมน้ำหนักเท่ากับ 100 (หน้าแก้ไขในแอปบังคับให้เอง)
⚠️ endpoint นี้ เขียนทับทั้ง config ยกเว้น 4 ฟิลด์ความยาว (summaryStyle · coachingStyle · summaryInstructions · coachingInstructions) shortCallMaxSec และ customerScore ซึ่ง ถ้าไม่ส่งมา ระบบจะไม่แตะค่าเดิม — ระบบที่เขียนไว้ก่อนมีฟิลด์เหล่านี้จึงไม่เผลอรีเซ็ตค่าที่ลูกค้าตั้งไว้ทุกครั้งที่ยิง PUT · ถ้าอยากล้างคำสั่งเพิ่มเติมให้ส่งค่าว่าง "" มาชัด ๆ · ทุกอย่างในคำขอเดียวจะบันทึกพร้อมกันทั้งหมด หรือไม่บันทึกเลย
Scope: manage_recording_prompt
| field | type | notes |
|---|---|---|
businessName | string (≤200) | ชื่อที่ AI ใช้ — ถ้าเว้นว่างจะใช้ชื่อธุรกิจแทน |
businessAbout | string (≤4000) | ธุรกิจทำเกี่ยวกับอะไร (บริบทให้ AI) |
businessProducts | string (≤4000) | สินค้า/บริการที่พูดถึงในสาย |
tagDescriptions | object, required | 5 คีย์: normal, short, wrong_or_spam, no_answer, other (แต่ละค่า ≤1000) — ระบบจะเติมคำอธิบายเริ่มต้นให้คีย์ที่ไม่ส่งมาหรือส่งค่าว่างเอง และ response ของ GET จะคืนครบทั้ง 5 คีย์เสมอ |
scoreCategories | array 1–20, required | แต่ละหมวด { label (1–200), maxPoints (1–100 จำนวนเต็ม) } |
summaryStyle | short | standard | detailed | ความยาวของ summary — สั้น = 1–2 ประโยค, มาตรฐาน = 2–3 ประโยค (ค่าเดิมของระบบ), ละเอียด = 4–6 ประโยค · ไม่ส่งมาหรือส่งค่าที่ไม่รู้จัก = standard |
coachingStyle | short | standard | detailed | ความยาวของ recommendations — สั้น = 2–3 ข้อ (~400–800 ตัวอักษร), มาตรฐาน = 4–6 ข้อ (~1,000–2,000 ตัวอักษร, ค่าเดิมของระบบ), ละเอียด = 6–8 ข้อ (~2,000–3,500 ตัวอักษร) · ปรับแค่ความยาว กติกาการอ้างหลักฐานและการชี้จุดในสายเหมือนกันทุกแบบ |
summaryInstructions | string (≤2000) | คำสั่งของธุรกิจสำหรับส่วนสรุป — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง |
coachingInstructions | string (≤2000) | คำสั่งของธุรกิจสำหรับส่วนคำแนะนำ — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง |
shortCallMaxSec | int 1–600 | null | เกณฑ์สายสั้นของธุรกิจ หน่วยวินาที · null = กลับไปใช้ค่ากลางของระบบ · ไม่ส่งมา = ไม่แตะของเดิม |
customerScore | object | หมวดคะแนนลูกค้า { categories?, serviceCategories? } — ดู แก้หมวดคะแนนลูกค้า ด้านล่าง · ไม่ส่งมา = ไม่แตะทั้ง 2 ชุด |
curl -X PUT "https://phone.mcloud.co.th/api/v1/recordings/prompt-config" \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"businessName": "Acme Co.",
"businessAbout": "ขายอสังหาริมทรัพย์ ลูกค้าเป็นกลุ่มครอบครัว",
"businessProducts": "บ้านเดี่ยว, ทาวน์โฮม, คอนโด",
"tagDescriptions": {
"normal": "บทสนทนาปกติ",
"short": "สายสั้นมาก",
"wrong_or_spam": "โทรผิด/สแปม",
"no_answer": "ไม่รับสาย",
"other": "อื่นๆ"
},
"scoreCategories": [
{ "label": "การทักทายและมารยาท", "maxPoints": 50 },
{ "label": "การปิดการสนทนา/นัดหมาย", "maxPoints": 50 }
],
"summaryStyle": "short",
"coachingStyle": "short",
"summaryInstructions": "",
"coachingInstructions": "เน้นเรื่องการปิดการขายและการนัดหมาย"
}'
{ "ok": true }
ถ้าเปิดใช้คะแนนลูกค้าอยู่ response จะมี customerScore มาด้วย — คือค่าที่ใช้อยู่หลังบันทึก หน้าตาเหมือนใน GET ทุกอย่าง
แก้หมวดคะแนนลูกค้า
ใส่ customerScore ใน body · แยกทีละชุด: ไม่ส่งมา = ไม่แตะของเดิม · null = กลับไปใช้ชุดตั้งต้นของระบบ · ส่งเป็นรายการ = ชุดของธุรกิจเอง · ต้องส่งมาอย่างน้อย 1 ชุด
"customerScore": {
"categories": [
{ "label": "ความต้องการชัดเจน", "maxPoints": 40 },
{ "label": "งบประมาณ", "maxPoints": 30 },
{ "label": "นัดขั้นตอนถัดไป", "maxPoints": 30 }
],
"serviceCategories": null
}
- 1 ชุดมีได้ 1–10 หมวด ·
labelยาว 1–80 ตัวอักษร ·maxPointsเป็นจำนวนเต็ม 0–100 และmaxPointsทั้งชุดต้องรวมได้ 100 พอดี - หมวดของธุรกิจเก็บแค่ชื่อกับน้ำหนัก ถ้าส่ง
hintมาจะถูกตัดทิ้ง - ⚠️ ถ้าอยากใช้ชุดตั้งต้นของระบบต่อไป อย่าส่งชุดนั้นมา · ถ้าส่ง
categoriesที่อ่านได้ตอนisDefaultเป็นtrueกลับมา ระบบจะบันทึกชุดตั้งต้นนั้นเป็นของธุรกิจเอง (โดยไม่มี hint) และจะไม่เปลี่ยนตามเมื่อระบบปรับชุดตั้งต้นอีก - ใช้ได้เฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่ (คือตอนที่
GETคืนcustomerScoreมา) · ถ้าส่งมาตอนที่ปิดอยู่ ระบบจะปฏิเสธทั้งคำขอด้วย403 customer_score_disabledและไม่บันทึกอะไรเลย ให้ส่งใหม่โดยไม่ใส่customerScore
จะคืน 422 validation_failed เมื่อข้อมูลไม่ถูกต้อง — เช่น ขาดคีย์ของแท็ก, scoreCategories ว่าง, maxPoints อยู่นอกช่วง 1–100 หรือหมวดคะแนนลูกค้าที่น้ำหนักรวมไม่ได้ 100 · กรณีนี้ระบบจะไม่บันทึกอะไรเลย
GET /recordings/detail
ทุกอย่างของสายเดียว จบในคำขอเดียว — ข้อมูลสายพร้อมผลวิเคราะห์ที่เก็บไว้ แปลงเป็น turn ให้เรียบร้อยแล้ว
แพ็กเกจ: ตอบ
403 feature_not_in_packageถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
GET /recordings/{id} คืนแถวข้อมูลกับ URL อีก 3 อันที่ผู้เรียกต้องไปดึงและ parse เอง แถมไฟล์ยังมี 2 รูปแบบตามยุคที่บันทึก · endpoint นี้อ่านไฟล์เหล่านั้นให้ที่ฝั่ง server แล้วคืนมาเป็นโครงสร้างพร้อมใช้ ระบบภายนอก (หรือ tool call ของ LLM) จึงได้คำตอบครบในคำขอเดียว
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
id | uuid | required |
include | รายการคั่นด้วยจุลภาคจาก transcript, summary, coaching, sentiment, flags, customerFields | default summary — เลือกเฉพาะส่วนที่ต้องการได้ เพราะบทถอดเสียงมีขนาดใหญ่ที่สุด |
maxTurns | int 1-2000 | default 200 — นับแยกในแต่ละส่วน · ดู truncated กับ totalTurns เพื่อรู้ว่าถึงเพดานหรือยัง |
curl "https://phone.mcloud.co.th/api/v1/recordings/detail?id=0192b9...&include=summary,transcript&maxTurns=100" \
-H "Authorization: Bearer crk_..."
{
"call": {
"id": "0192b9...",
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T01:14:00.000Z",
"direction": "in",
"customerPhone": "+66812345678",
"tag": "normal",
"score": 86,
"customerScore": 72,
"customerScoreKind": "sales",
"customerReadinessScore": 72,
"customerSatisfactionScore": 81,
"durationMs": 214000,
"processingStatus": "completed",
"aiProcessed": true
},
"include": ["summary", "transcript"],
"summary": {
"text": "ลูกค้าสอบถามโปรโมชั่นบ้านเดี่ยว...",
"recommendations": "- [0:50-1:00] ตอนลูกค้าถามเรื่องราคา ควรเสนอ...",
"keyPoints": ["สนใจโครงการ A", "ขอใบเสนอราคา"],
"concerns": ["กังวลเรื่องดอกเบี้ย"],
"nextSteps": ["ส่งใบเสนอราคาภายในวันนี้"],
"score": 86,
"tag": "normal",
"overallEmotion": "positive",
"overallTone": "friendly",
"customerScore": 72,
"customerScoreBreakdown": [
{ "label": "ความต้องการชัดเจน", "weight": 30, "pct": 82, "reason": "บอกทำเลและขนาดบ้านที่ต้องการ" },
{ "label": "ความพร้อมด้านงบประมาณ", "weight": 25, "pct": 60, "reason": "มีงบแต่ยังกังวลเรื่องดอกเบี้ย" },
{ "label": "ช่วงเวลาตัดสินใจ", "weight": 20, "pct": null, "reason": "ไม่ได้พูดถึงกำหนดเวลา" },
{ "label": "การตอบรับขั้นตอนถัดไป", "weight": 25, "pct": 72, "reason": "ขอใบเสนอราคา" }
],
"customerScoreKind": "sales",
"customerScoreKindReason": "ลูกค้าโทรมาสอบถามโครงการเพื่อซื้อ",
"customerReadinessScore": 72,
"customerReadinessBreakdown": [
{ "label": "ความต้องการชัดเจน", "weight": 30, "pct": 82, "reason": "บอกทำเลและขนาดบ้านที่ต้องการ" },
{ "label": "ความพร้อมด้านงบประมาณ", "weight": 25, "pct": 60, "reason": "มีงบแต่ยังกังวลเรื่องดอกเบี้ย" },
{ "label": "ช่วงเวลาตัดสินใจ", "weight": 20, "pct": null, "reason": "ไม่ได้พูดถึงกำหนดเวลา" },
{ "label": "การตอบรับขั้นตอนถัดไป", "weight": 25, "pct": 72, "reason": "ขอใบเสนอราคา" }
],
"customerSatisfactionScore": 81,
"customerSatisfactionBreakdown": [
{ "label": "ปัญหา/คำถามได้รับการแก้ไข", "weight": 35, "pct": 80, "reason": "ได้คำตอบเรื่องโปรโมชั่นครบ" },
{ "label": "ความพึงพอใจต่อการบริการ", "weight": 30, "pct": 85, "reason": "ขอบคุณและน้ำเสียงดี" },
{ "label": "ความมั่นใจต่อบริการ", "weight": 20, "pct": 75, "reason": "ยังกังวลเรื่องดอกเบี้ยเล็กน้อย" },
{ "label": "ความรวดเร็วและง่าย", "weight": 15, "pct": null, "reason": "" }
],
"agentScoreReasons": [
{ "label": "การทักทาย", "reason": "แนะนำตัวและบริษัทครบ" },
{ "label": "การนำเสนอ", "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" }
],
"scoreBreakdown": [
{ "label": "การทักทาย", "weight": 20, "pct": 100, "reason": "แนะนำตัวและบริษัทครบ" },
{ "label": "การนำเสนอ", "weight": 50, "pct": 84, "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" },
{ "label": "การปิดการขาย", "weight": 30, "pct": 80, "reason": null }
]
},
"transcript": {
"turns": [
{ "index": 0, "speaker": "แอดมิน", "side": "admin", "startMs": 0, "endMs": 2400, "text": "สวัสดีค่ะ" },
{ "index": 1, "speaker": "ลูกค้า", "side": "customer", "startMs": 2400, "endMs": 7100, "text": "อยากสอบถามโปรโมชั่นค่ะ" }
],
"totalTurns": 148,
"returnedTurns": 100,
"maxTurns": 100,
"truncated": true
}
}
summary.customerScoreBreakdown มีทุกหมวดของชุดคะแนนลูกค้าของธุรกิจ: weight น้ำหนักเป็นเปอร์เซ็นต์ (รวมกันได้ 100) · pct ที่สายนี้ได้ในหมวดนั้น (0-100) · reason เหตุผลสั้น ๆ · pct: null แปลว่าสายนี้ไม่มีข้อมูลให้ประเมินหมวดนั้น ระบบไม่นับหมวดนั้น (ไม่ได้นับเป็น 0) · customerScore กับ customerScoreBreakdown เป็น null ถ้าสายนี้ยังไม่เคยถูกประเมิน (ดู customerScore ด้านบน)
summary.customerReadinessScore / summary.customerSatisfactionScore คือคะแนนลูกค้าทั้ง 2 ฝั่งของเวอร์ชันนี้ — ความพร้อมซื้อตามหมวดการขาย และความพึงพอใจตามหมวดงานบริการ — พร้อมรายหมวด summary.customerReadinessBreakdown / summary.customerSatisfactionBreakdown รูปแบบเดียวกับ customerScoreBreakdown (รายหมวดของฝั่งหลักคือรายการเดียวกับ customerScoreBreakdown) · อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด แต่รายหมวดยังอาจมีครบทุกหมวดโดย pct: null · เวอร์ชันที่ประเมินก่อนมีคะแนน 2 ฝั่ง มี customerScore และ customerScoreBreakdown อยู่ในฝั่งตามประเภท ส่วนอีกฝั่งเป็น null
summary.customerScoreKind บอกว่าเป็นคะแนนลูกค้าประเภทไหน (sales หรือ service · null ถ้ายังไม่ได้ประเมิน) และ summary.customerScoreKindReason คือเหตุผลหนึ่งบรรทัดที่ระบบดูว่าเป็นประเภทนั้น (null ถ้าไม่มี) · summary.agentScoreReasons คือเหตุผลหนึ่งบรรทัดของแต่ละหมวดคะแนนเทเล เรียงตามหมวด ว่าทำไมสายนี้ได้คะแนนหมวดนั้นเท่านั้น รอบประเมินเดียวกันเป็นผู้เขียน และไม่เปลี่ยน score · เป็น null ในสายที่ประเมินก่อนมีข้อมูลนี้
summary.scoreBreakdown คือคะแนนเทเลรายหมวด — agentScoreReasons ยังส่งมาเหมือนเดิม ส่วน scoreBreakdown มีเหตุผลชุดเดียวกันนั้นพร้อมน้ำหนัก (weight เป็นเปอร์เซ็นต์) และคะแนนที่สายนี้ได้ (pct จำนวนเต็ม 0-100) ของแต่ละหมวด · กติกาเหมือน result.scoreBreakdown ของ POST /recordings/analyze ทุกอย่าง: หมวดที่ไม่มีเหตุผลได้ reason: null และเป็น null ทั้งรายการถ้าผลวิเคราะห์ไม่ได้เก็บคะแนนรายหมวดไว้ (หรือในบางครั้งที่ระบบอ่านไม่ได้ในตอนนั้น — ส่วนอื่นของสรุปยังส่งมาครบ) · เป็นรายการเดียวกับที่แท็บ "คะแนน" ในหน้าสายแสดง
โครงสร้างของส่วนอื่น:
| ส่วน | โครงสร้าง |
|---|---|
coaching | { recommendations, turns[{ index, speaker, side, text, coaching, evidence }], totalTurns, returnedTurns, truncated } |
sentiment | { overallEmotion, overallTone, emotionCounts, toneCounts, turns[{ index, speaker, side, emotion, tone }], totalTurns, returnedTurns, truncated } |
flags | [{ key, label, severity, quote, reason, startMs }] — Flag ชุดเดียวกับ call.flags พร้อมประโยคในสายที่ใช้ตัดสิน (quote) เหตุผลสั้น ๆ (reason) และตำแหน่งในไฟล์เสียงที่ประโยคนั้นเริ่ม (startMs, มิลลิวินาที) |
customerFields | [{ key, label, type, value, quote }] — คำตอบข้อมูลลูกค้าที่บันทึกไว้กับสายนี้ เรียงตามลำดับคำถามของธุรกิจ |
curl "https://phone.mcloud.co.th/api/v1/recordings/detail?id=0192b9...&include=flags,customerFields" \
-H "Authorization: Bearer crk_..."
{
"call": {
"id": "0192b9...",
"flags": [
{ "key": "churn_risk", "label": "ลูกค้าจะยกเลิก", "severity": "critical" }
],
"aiProcessed": true
},
"include": ["flags", "customerFields"],
"flags": [
{
"key": "churn_risk",
"label": "ลูกค้าจะยกเลิก",
"severity": "critical",
"quote": "ถ้ายังแก้ไม่ได้ ผมขอยกเลิกเลยนะครับ",
"reason": "ลูกค้าบอกชัดว่าจะยกเลิกถ้าปัญหายังไม่ได้รับการแก้",
"startMs": 83400
}
],
"customerFields": [
{ "key": "name", "label": "ชื่อลูกค้า", "type": "text", "value": "สมชาย", "quote": "ผมชื่อสมชายครับ" },
{ "key": "follow_up_date", "label": "นัดติดต่อกลับ", "type": "date", "value": "2026-05-22", "quote": "รบกวนโทรกลับวันที่ 22 นะครับ" },
{ "key": "budget", "label": "งบประมาณ", "type": "number", "value": 30000, "quote": "งบประมาณสักสามหมื่นครับ" }
]
}
callคือ object เดียวกับที่GET /recordings/{id}คืน บวกaiProcessed— boolean ที่บอกว่าสายนี้ผ่านการวิเคราะห์ด้วย AI สำเร็จอย่างน้อยหนึ่งครั้งแล้ว · ระบบไม่ส่งกลับว่าใช้โมเดลไหน ผู้ให้บริการใด หรือต้นทุนเท่าไร —aiProcessedคือสัญญาณเดียวเกี่ยวกับ AI ใน endpoint นี้- ส่วนที่ขอมาจะเป็น
nullเมื่อไฟล์ผลวิเคราะห์หายไปหรืออ่านไม่ได้ ซึ่งไม่เหมือนกับส่วนที่มีอยู่แต่ว่างเปล่า sideเป็นค่าที่เครื่องอ่านได้:admin/customer/unknown· ส่วนspeakerคือคำเรียกที่ธุรกิจตั้งไว้เองสำหรับฝั่งนั้น (ตั้งที่/app/prompt) ค่าเริ่มต้นจึงเป็นภาษาไทย · ถ้าเป็นบุคคลที่สามที่มีชื่อจริง ระบบจะคงชื่อนั้นไว้และให้side: "unknown"coaching.totalTurnsและsentiment.totalTurnsนับเฉพาะ turn ที่มีคำอธิบายกำกับ (มีคำแนะนำ หรือมีอารมณ์/น้ำเสียง) ไม่ใช่ทุก turn ในสาย · ส่วนtranscript.totalTurnsนับครบทุก turnemotionCounts/toneCountsนับจากทั้งสาย ไม่ใช่แค่หน้า turn ที่คืนมาflagsกับcustomerFieldsไม่เป็นnullเพราะเป็นข้อมูลในฐานข้อมูล ไม่ใช่ไฟล์ที่อาจหาย ถ้าไม่มีจะได้[]· สายที่เชื่อมมาจากธุรกิจอื่นได้[]ทั้งสองส่วนเสมอvalueในcustomerFieldsเป็นชนิดตามtype: ข้อความ (text,choice) ตัวเลข (number) วันที่YYYY-MM-DD(date) หรือ boolean (yes_no) ·nullแปลว่ามีคนลบคำตอบของ AI ออกเพราะไม่ถูกต้องquoteเป็นnullเมื่อ Flag หรือคำตอบนั้นมีคนใส่เอง · คำตอบของคำถามที่ธุรกิจปิดหรือเก็บถาวรไปแล้วยังส่งมาด้วย เพราะเป็นข้อมูลที่ได้จากสายนั้นจริงincludeที่ไม่ใช่ 6 ชื่อนี้จะได้400 validation_failed· สายที่ API key มองไม่เห็นจะได้404 not_foundไม่ใช่403เพราะ 403 เท่ากับยืนยันว่า id นั้นมีอยู่จริง
GET /recordings/insight-config
รายการ Flag และคำถามข้อมูลลูกค้าทั้งหมดที่ธุรกิจตั้งไว้ ใช้แปล key ที่เจอในที่อื่นของ API — flags ของแต่ละสาย, ตัวกรอง flag, include=flags,customerFields และ call.flags ใน callback — ให้เป็นชื่อที่คนอ่านและระดับของ Flag
Scopes: view_recording_all · view_recording_self
curl "https://phone.mcloud.co.th/api/v1/recordings/insight-config" \
-H "Authorization: Bearer crk_..."
{
"flags": [
{ "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical", "active": true, "archived": false },
{ "key": "deal_closed", "label": "ปิดการขายได้", "severity": "positive", "active": true, "archived": false },
{ "key": "f_k3x9q2m1za", "label": "ลูกค้าถามเรื่องผ่อน 0%", "severity": "info", "active": false, "archived": true }
],
"fields": [
{ "key": "name", "label": "ชื่อลูกค้า", "type": "text", "options": [], "unit": null, "active": true, "archived": false },
{ "key": "follow_up_date", "label": "นัดติดต่อกลับ", "type": "date", "options": [], "unit": null, "active": true, "archived": false },
{ "key": "budget", "label": "งบประมาณ", "type": "number", "options": [], "unit": "บาท", "active": false, "archived": false }
]
}
| ฟิลด์ | ความหมาย |
|---|---|
key | ตัวระบุถาวร เปลี่ยนไม่ได้ — Flag และคำถามที่มากับระบบใช้ชื่อภาษาอังกฤษ (customer_complaint, follow_up_date …) ส่วนที่ธุรกิจสร้างเองได้ key ที่สุ่มขึ้น ขึ้นต้นด้วย f_ (Flag) หรือ q_ (คำถาม) |
label | ชื่อที่ธุรกิจตั้ง แก้ได้ตลอด จึงควรอ่านจากที่นี่แทนการเขียนตายตัวไว้ในระบบของคุณ |
severity | ระดับของ Flag: critical เร่งด่วน · warning ควรดู · positive ดี · info ข้อมูล |
type | ชนิดคำตอบ: text · number · date · choice (คำตอบเป็นค่าหนึ่งใน options) · yes_no |
options | ตัวเลือกของคำถามแบบ choice ชนิดอื่นเป็น [] |
unit | หน่วยของคำตอบที่เป็นตัวเลข เช่น บาท หรือ null |
active | AI ตรวจข้อนี้ในสายใหม่อยู่ (เปิดอยู่และยังไม่เก็บถาวร) |
archived | ธุรกิจเลิกใช้แล้ว แต่ยังอยู่ในรายการเพราะสายเก่าอาจยังมี key นี้ |
- ข้อที่ยังใช้อยู่ขึ้นก่อนตามลำดับที่ธุรกิจจัด ข้อที่เก็บถาวรต่อท้าย
- endpoint นี้อ่านได้อย่างเดียว การเพิ่มหรือแก้ Flag และคำถามทำในแอป เพราะรายการนี้มีผลกับค่าวิเคราะห์ของทุกสาย
ค้นหาข้อความในบทสนทนา
การค้นว่า "มีใครพูดคำนี้ไว้ตรงไหนบ้าง" ครอบคลุมทุกสายที่ API key มองเห็น อยู่ที่ GET /transcripts/search — ดูหน้า Transcripts ซึ่งครอบคลุมพารามิเตอร์ทั้งหมด การแบ่งหน้าด้วย cursor และวิธีอ่าน matchedCalls / callsScanned
Insights
6 endpoint อ่านอย่างเดียวใต้ /insights/** ออกแบบให้คำตอบทั้งชุดพอดีกับ 1 response — สำหรับผู้ช่วย AI และหน้าสรุปตัวเลข ไม่ใช่สำหรับ export ทีละแถว
แพ็กเกจ: ทุก endpoint ใต้
/insights/**ตอบ403 feature_not_in_packageถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
ทุกตัวเลขใช้นิยามเดียวกับหน้าภาพรวมในแอป (การแบ่งสาย 4 ทาง เกณฑ์รับสาย/สายหลุด ช่วงคะแนน funnel) คำตอบที่ได้จาก API นี้จึงตรงกับหน้า /app/dashboard เสมอ
Scopes (ทั้ง 6 endpoint): view_recording_all · view_recording_dashboard · view_recording_self
API key ที่ไม่มี view_recording_all (หรือ manage_all) จะนับเฉพาะสายที่ผูกกับผู้ใช้เจ้าของ key เท่านั้น
พารามิเตอร์ร่วม — ทุก insights endpoint รับชุดนี้:
| Param | Type | Note |
|---|---|---|
dateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
tz | IANA timezone | default Asia/Bangkok — เป็นเขตเวลาที่ใช้ตัด bucket วัน/ชั่วโมงด้วย |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะพนักงานเหล่านี้ — key เดียวกับ /recordings/agents · /insights/agents ไม่รับพารามิเตอร์นี้ ให้ใช้ search แทน |
ระบบอ่านช่วงเวลาอย่างไร
- ถ้าไม่ส่งขอบเขตทั้งสองข้าง จะนับทั้งหมดตั้งแต่ต้น และไม่มีช่วงก่อนหน้าให้เทียบ:
period.previousกับcomparisonทุกตัวจะเป็นnull(ถ้าใส่ค่าเริ่มต้น 30 วันตรงนี้ จะเกิดบั๊กที่ปุ่ม "ทั้งหมด" กลายเป็นแค่ช่วงหนึ่งโดยไม่มีใครรู้) - ถ้าส่งแค่
dateToช่วงจะจบตรงนั้นและเริ่มก่อนหน้า 30 วัน · ถ้าส่งแค่dateFromช่วงจะจบที่ปัจจุบัน - นอกนั้น ช่วงเปรียบเทียบยาวเท่ากันและไปจบตรงจุดที่ช่วงนี้เริ่ม
ทุก response เปิดด้วยบล็อกเดียวกันนี้:
{
"period": {
"from": "2026-07-31T17:00:00.000Z",
"to": "2026-08-31T16:59:59.999Z",
"tz": "Asia/Bangkok",
"allTime": false,
"previous": { "from": "2026-06-30T17:00:00.000Z", "to": "2026-07-31T17:00:00.000Z" }
}
}
คำว่า "sentiment" ในที่นี้หมายถึงอะไร ผลอารมณ์รายสายที่ AI ตัดสินอยู่ในไฟล์ผลวิเคราะห์บน object storage ไม่มีคอลัมน์ในฐานข้อมูล จึงรวมยอดด้วย SQL ไม่ได้ และการเปิดไฟล์ทีละสายก็ทำไม่ไหว · ที่ endpoint กลุ่มนี้เรียกว่า sentiment คือคะแนน 0-100 ของ AI ที่แบ่งช่วงที่ 70 / 40 ซึ่งเป็นจุดตัดเดียวกับการกระจายคะแนนในหน้าภาพรวม · ทุก response บอกไว้ตรง ๆ ด้วย basis: "score" และ thresholds จะได้ไม่ต้องเดาว่ากำลังอ่านตัวไหนอยู่
POST /insights/overview · /insights/agents · /insights/timeseries
สามตัวนี้ตอบ POST ได้ด้วย โดยส่งพารามิเตอร์มาใน JSON body — สำหรับตัวกรองที่ใส่ใน URL ไม่ได้ โดยเฉพาะรายการเบอร์ลูกค้าจาก CRM · response เหมือน GET ของตัวเองทุกประการ · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ
Scopes: เหมือน GET — view_recording_all · view_recording_dashboard · view_recording_self
body รับพารามิเตอร์ทุกตัวของ GET ด้วยชื่อเดิม (agentKey เป็น array ไม่เกิน 500 ค่า) พร้อมตัวกรองสายอีก 4 ตัว:
| ฟิลด์ | Type | Note |
|---|---|---|
customerPhones | string[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร) | เฉพาะสายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key — รูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร) |
tags | array ของ normal | short | wrong_or_spam | no_answer | other | none | เฉพาะสายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก |
callcenterNames | string[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร) | เฉพาะสายที่ชื่อพนักงานที่บันทึกไว้กับสายตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร — แบบเดียวกับ POST /recordings/search · ค่าที่ใช้ได้ดูจาก GET /recordings/callcenter-names |
direction | in | out | เฉพาะสายเข้าหรือสายออกตามที่ระบุ |
curl -X POST https://phone.mcloud.co.th/api/v1/insights/timeseries \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"metric": "calls",
"bucket": "day",
"dateFrom": "2026-10-01",
"dateTo": "2026-10-07",
"customerPhones": ["0812345678", "+66 89-876-5432"],
"tags": ["normal"]
}'
- ตัวกรองกรองช่วงก่อนหน้าแบบเดียวกับช่วงปัจจุบันทุกประการ
comparisonและsummary.previousจึงเทียบของชนิดเดียวกัน · บน/insights/agentsตัวกรองมีผลทั้งกับหน้านั้นtotalและ comparison ของพนักงานทุกคน /insights/agentsไม่รับagentKeyใน body เช่นกัน — ถ้าส่งมาจะได้400ให้ใช้searchแทน- body ใช้กติกาของ Phones → Body ของคำขอแบบ batch: คีย์ที่ไม่รู้จักได้
400·nullในค่าเดี่ยว (dateFrom,tz,metric,limit, …) ถือว่าไม่ได้ตั้ง ·customerPhones: [],tags: [],callcenterNames: []หรือagentKey: []ไม่ตรงกับสายไหนเลย ·agentKeyเกิน 500 ค่าได้400(ส่วนGETตัดส่วนเกินทิ้งเงียบ ๆ) /insights/topics,/insights/questionsและ/insights/qualityมีแค่GET
GET /insights/overview
ภาพรวมพาดหัวของช่วงเวลา: ตัวเลขราว 40 ค่า ไม่มี array และมีช่วงก่อนหน้าที่ยาวเท่ากันวางคู่ไว้ให้ ตอบคำถาม "ดีขึ้นไหม" ได้โดยไม่ต้องยิงคำขอที่สอง
พารามิเตอร์: ชุดร่วมเท่านั้น
curl "https://phone.mcloud.co.th/api/v1/insights/overview?dateFrom=2026-08-01&dateTo=2026-08-31" \
-H "Authorization: Bearer crk_..."
{
"period": { "from": "2026-07-31T17:00:00.000Z", "to": "2026-08-31T16:59:59.999Z", "tz": "Asia/Bangkok", "allTime": false, "previous": { "from": "2026-06-30T17:00:00.000Z", "to": "2026-07-31T17:00:00.000Z" } },
"calls": {
"total": 1284,
"answered": 1102,
"missed": 182,
"split": { "inbound": 812, "outbound": 290, "missedInbound": 151, "missedOutbound": 31 },
"answerRate": 0.8583,
"firstCallAt": "2026-08-01T01:12:00.000Z",
"lastCallAt": "2026-08-31T12:45:00.000Z"
},
"duration": { "talkTimeMs": 203112000, "avgMs": 184320.5, "longestMs": 1432000 },
"sentiment": {
"basis": "score",
"thresholds": { "positive": 70, "neutral": 40 },
"positive": 612, "neutral": 388, "negative": 96, "unscored": 188, "scored": 1096,
"avgScore": 78.4
},
"aiProcessed": {
"completed": 1096, "pending": 12, "transcribing": 3,
"failed": 5, "insufficientCredit": 0, "coverage": 0.8536
},
"comparison": {
"calls": { "previous": 1190, "changePct": 7.9 },
"answered": { "previous": 1004, "changePct": 9.76 },
"missed": { "previous": 186, "changePct": -2.15 },
"talkTimeMs": { "previous": 191400000, "changePct": 6.12 },
"avgScore": { "previous": 76.1, "changePct": 3.02 },
"aiCoverage": { "previous": 0.81, "changePct": 5.38 }
}
}
- 4 ค่าใน
splitแบ่งtotalได้พอดี:total = inbound + outbound + missedInbound + missedOutbound· "สายหลุด" คือคำตัดสินno_answerของ AI (กริ่งดังแต่ไม่มีคนรับ ไม่มีบทสนทนา) ตรงกับที่คนหน้างานเรียกว่าสายที่ไม่ได้รับ duration.talkTimeMsรวมเฉพาะสายที่รับ · สายที่ไม่มีคนรับมีระยะเวลากริ่งซึ่งไม่มีใครได้คุย ถ้านับรวมจะทำให้ชั่วโมงคุยของทุกคนพองเกินจริงanswerRateและcoverageเป็นสัดส่วน 0-1 และเป็นnullเมื่อไม่มีตัวหารให้คำนวณchangePctเป็นnullเมื่อไม่มีอัตราส่วนให้รายงาน — ข้างใดข้างหนึ่งหายไป หรือค่าเดิมเป็นศูนย์ (เพราะ "+100%" จากศูนย์อ่านเหมือนเป็นแนวโน้ม ทั้งที่แค่เพิ่งมีสายแรก)
GET /insights/agents
ตารางรายคนของช่วงเวลา: ปริมาณสาย การแบ่ง 4 ทาง เวลาคุย คะแนนจาก AI และตัวเลขช่วงก่อนหน้าของแต่ละคน จึงตอบได้ในการอ่านครั้งเดียวว่า "ผลงานของใครตกลง"
ตัวตนของพนักงานคือ callcenterNumber ถ้าไม่มีเบอร์จึงใช้ callcenterName — key ตัวเดียวกับที่ /recordings/agents คืน จึงสลับไปมาระหว่าง endpoint ได้โดยไม่ต้องมีตารางแปลง · ชื่อที่แสดงเลือกแบบเดียวกับกระดานทีมในแอปทุกประการ: ใช้ชื่อที่แอดมินตั้งทับก่อน ถ้าไม่มีจึงใช้ชื่อจาก provider และถ้ายังไม่มีจึงใช้ชื่อที่บันทึกไว้กับสาย
| Param | Type | Note |
|---|---|---|
search | string | ค้นได้ทั้งเบอร์ภายใน ชื่อที่บันทึกไว้กับสาย ชื่อในรายชื่อของ provider และชื่อที่ตั้งทับ |
sortBy | calls | answered | missed | talkTime | avgScore | answerRate | name | default calls |
sortDir | asc | desc | default desc · ค่า null อยู่ท้ายสุด ถ้าเท่ากันจะเรียงตาม key ของพนักงาน |
limit | int 1-200 | default 50 (response บอกเพดานไว้ที่ maxLimit) |
offset | int ≥ 0 | default 0 |
endpoint นี้ไม่รับ agentKey — ให้ใช้ search หรืออ่านทั้งหน้าแทน
curl "https://phone.mcloud.co.th/api/v1/insights/agents?dateFrom=2026-08-01&sortBy=avgScore&sortDir=asc&limit=20" \
-H "Authorization: Bearer crk_..."
{
"period": { "…": "เหมือนด้านบน" },
"sentiment": { "basis": "score", "thresholds": { "positive": 70, "neutral": 40 } },
"sort": { "by": "avgScore", "dir": "asc" },
"limit": 20,
"offset": 0,
"maxLimit": 200,
"total": 34,
"items": [
{
"key": "12110",
"agentId": "0192aa...",
"extension": "12110",
"name": "Somchai",
"isActive": true,
"calls": 214,
"answered": 190,
"missed": 24,
"split": { "inbound": 150, "outbound": 40, "missedInbound": 20, "missedOutbound": 4 },
"answerRate": 0.8878,
"talkTimeMs": 37642000,
"avgDurationMs": 176000,
"avgScore": 64.2,
"scored": 188,
"sentiment": { "positive": 60, "neutral": 98, "negative": 30, "unscored": 26 },
"lastCallAt": "2026-08-31T09:14:00.000Z",
"comparison": { "calls": 240, "callsChangePct": -10.83, "talkTimeMs": 41200000, "avgScore": 68.9, "avgScoreChangePct": -6.82 }
}
]
}
totalคือจำนวนพนักงานที่ไม่ซ้ำกันในช่วงนั้น ใช้คู่กับoffsetเพื่อแบ่งหน้าagentIdและisActiveมาจากรายชื่อพนักงานของ provider · เบอร์ภายในที่มีอยู่แค่ในประวัติสายจะได้agentId: nullและisActive: falsecomparisonแยกรายแถวและเป็นnullเมื่อดูแบบทั้งหมดตั้งแต่ต้น · คำนวณเฉพาะพนักงานที่อยู่ในหน้านี้เท่านั้น
GET /insights/timeseries
หนึ่ง metric แบ่งตามช่วงเวลา พร้อมค่าสรุปของ metric เดียวกันในช่วงก่อนหน้า เพื่อให้กราฟมีอะไรให้เทียบ · ระบบตัด bucket ตาม tz ไม่ใช่ UTC — ถ้าตัดฮิสโทแกรมรายชั่วโมงของคอลเซ็นเตอร์ในกรุงเทพฯ ตามเส้น UTC ช่วงเร่งด่วนตอนเช้าจะไปตกผิด bucket
| Param | Type | Note |
|---|---|---|
metric | calls | duration | sentiment | processed | default calls |
bucket | hour | day | week | month | default day |
บวกกับพารามิเตอร์ร่วม (รวม agentKey ที่ส่งซ้ำได้)
metric | แต่ละจุดมีอะไร |
|---|---|
calls | { bucket, total, inbound, outbound, missedInbound, missedOutbound } |
duration | { bucket, calls, talkTimeMs, avgMs } |
sentiment | { bucket, calls, positive, neutral, negative, unscored, avgScore } |
processed | { bucket, total, completed, pending, failed, coverage } |
curl "https://phone.mcloud.co.th/api/v1/insights/timeseries?metric=calls&bucket=day&dateFrom=2026-08-01&dateTo=2026-08-07" \
-H "Authorization: Bearer crk_..."
{
"period": { "…": "เหมือนด้านบน" },
"metric": "calls",
"bucket": "day",
"sentimentBasis": null,
"maxPoints": 500,
"truncated": false,
"points": [
{ "bucket": "2026-08-01", "total": 41, "inbound": 30, "outbound": 8, "missedInbound": 3, "missedOutbound": 0 },
{ "bucket": "2026-08-02", "total": 36, "inbound": 25, "outbound": 9, "missedInbound": 2, "missedOutbound": 0 }
],
"summary": { "current": 1284, "previous": 1190, "changePct": 7.9 }
}
- รูปแบบป้าย bucket:
hour→2026-08-01T09:00,day→2026-08-01,week→ วันจันทร์ของสัปดาห์นั้น,month→2026-08 - จุดเรียงจากเก่าไปใหม่ สูงสุด 500 จุด (
maxPoints) · ถ้าเกิน ระบบตัดปลายเก่าสุดทิ้งและตั้งtruncatedเป็นtrue— ให้ย่อช่วงหรือขยาย bucket แทนที่จะอ่านกราฟที่ถูกตัดว่าเป็นคำตอบทั้งหมด summary.current/summary.previousคือตัวเลขเดียวที่สรุป metric นั้นทั้งช่วง: จำนวนสาย, เวลาคุยรวมเป็นมิลลิวินาที, คะแนนเฉลี่ย หรือสัดส่วน 0-1sentimentBasisจะเป็น{ basis, thresholds }เฉพาะตอนmetric=sentimentนอกนั้นเป็นnull
GET /insights/topics
สายในช่วงนั้นคุยเรื่องอะไรบ้าง ตามการจัดหมวดของธุรกิจเอง พร้อมช่วงก่อนหน้าวางคู่ทุกแถว
หมวดที่ใช้คือ tag ของสาย — หมวดที่ AI จัดให้ตามคำอธิบายที่ธุรกิจเขียนไว้เองที่ /app/prompt และทุกแถวคืนคำอธิบายนั้นกลับมาด้วย ผู้อ่านจึงรู้ว่าคำว่า "short" หมายถึงอะไรสำหรับธุรกิจนี้ แทนที่จะเดาจากชื่อ enum · ส่วนหัวข้อแบบข้อความอิสระรายสายอยู่ในไฟล์ผลวิเคราะห์และไม่มีคอลัมน์ให้จัดกลุ่ม จึงตั้งใจไม่เอามาอ้างตรงนี้
พารามิเตอร์: ชุดร่วม (รวม agentKey ที่ส่งซ้ำได้)
{
"period": { "…": "เหมือนด้านบน" },
"basis": "call_tag",
"total": 1284,
"items": [
{
"tag": "normal",
"description": "บทสนทนาปกติ มีเนื้อหา/ติดต่อธุรกิจจริง",
"calls": 742,
"share": 0.5779,
"answered": 742,
"missed": 0,
"talkTimeMs": 168420000,
"avgScore": 81.3,
"scored": 731,
"comparison": { "calls": 690, "changePct": 7.54, "share": 0.5798 }
}
]
}
- คืนหมวดครบชุดเสมอ —
normal,short,wrong_or_spam,no_answer,otherและnoneสำหรับสายที่ AI ยังไม่จัดหมวด — เรียงจากมากไปน้อย · หมวดที่มี 0 สายก็ยังปรากฏ ไม่หายไปเงียบ ๆ descriptionเป็นnullสำหรับnoneเพราะไม่ใช่หมวดที่ธุรกิจอธิบายไว้ แต่แปลว่า "ยังไม่ได้จัดหมวด"shareเป็นสัดส่วน 0-1 ของสายในช่วงนั้น ส่วนcomparison.shareคือสัดส่วนของแท็กเดียวกันในช่วงก่อนหน้า เพราะถ้าหมวดอื่นโตขึ้น สัดส่วนก็ขยับได้แม้ปริมาณจะเท่าเดิม
GET /insights/questions
ลูกค้าพูดถึงเรื่องอะไรบ้างในช่วงนั้น และสายเหล่านั้นทำได้ดีแค่ไหน ต่างจาก /insights/topics ตรงที่ตัวนั้นรายงานแท็ก ชุดตายตัว 5 หมวด ส่วนตัวนี้เป็น open-vocabulary คือค้นหาเองว่าลูกค้าพูดเรื่องอะไร โดยไม่ต้องมีใครประกาศหัวข้อไว้ล่วงหน้า
นับเฉพาะคำพูดของลูกค้าเท่านั้น เพราะเทเลพูดสคริปต์เดิมทุกสาย ถ้านับทั้งสองฝั่ง คำทักทายจะชนะทุกหัวข้อจริง — วัดจากข้อมูลจริงบน production วลีที่พบบ่อยที่สุด 3 อันดับแรกของธุรกิจหนึ่งใน 1 เดือนคือ สวัสดีค่ะ, ครับ, ฮัลโหล ซึ่งเป็นฝั่งเทเลทั้งหมด
การจัดอันดับใช้ log-odds ratio พร้อม informative Dirichlet prior (Monroe, Colaresi & Quinn 2008) เทียบกับคลังข้อความรวมทั้งแพลตฟอร์ม — ไม่ใช่ความถี่ดิบ (ซึ่งจะได้แต่คำลงท้าย) และไม่ใช่ TF-IDF (ซึ่งให้น้ำหนักคำหายากมากเกินไป) ค่า z-score ส่งกลับมาในชื่อ distinctiveness
วลีที่เขียนต่างกันเล็กน้อยจะถูกรวมเป็นวลีเดียวโดยดูว่าตัวสะกดคล้ายกันแค่ไหน จึงรวมได้เฉพาะรูปเขียน/คำลงท้ายที่ต่างกัน ไม่ใช่คำพ้องความหมาย: กี่บาท กับ ราคาเท่าไหร่ ไม่มีตัวอักษรช่วงไหนตรงกันเลย จึงไม่ถูกรวม การตั้งชื่อหัวข้อจากคลัสเตอร์เหล่านี้เป็นหน้าที่ของผู้เรียก — head, phrases, examples ถูกจำกัดขนาดให้เรียก LLM ครั้งเดียวจบ โดยที่โมเดลไม่เคยเห็นบทสนทนาเลย
| Param | Type | Note |
|---|---|---|
minCalls | integer 2-100 ค่าเริ่มต้น 3 | วลีต้องปรากฏในกี่สายจึงจะเป็นตัวเลือก |
limit | integer 1-150 ค่าเริ่มต้น 60 | จำนวนคลัสเตอร์ที่คืน เรียงจากพบบ่อยสุด |
pool | integer 50-600 ค่าเริ่มต้น 300 | จำนวนวลีคะแนนสูงสุดที่เข้าสู่การจัดกลุ่ม |
similarity | number 0.1-0.95 ค่าเริ่มต้น 0.45 | เกณฑ์ความคล้ายที่ถือว่าเป็นวลีเดียวกัน |
{
"period": { "…": "เหมือนด้านบน" },
"basis": "customer_utterances",
"background": { "basis": "platform_excluding_self", "corpusTokens": 878319, "refreshedAt": "2026-08-15T02:00:00.000Z" },
"statistic": { "name": "log_odds_ratio_informative_dirichlet_prior", "reference": "Monroe, Colaresi & Quinn (2008)", "similarityThreshold": 0.45, "minCalls": 3 },
"sentiment": { "basis": "score_banded", "thresholds": { "positive": 70, "neutral": 40 } },
"calls": 5169,
"totalCalls": 4820,
"items": [
{
"clusterId": 6,
"head": "ไม่ได้ทานแล้ว",
"phrases": [
{ "phrase": "ไม่ได้ทานแล้ว", "calls": 207, "occurrences": 241, "z": 19.3 },
{ "phrase": "ไม่ได้ทาน", "calls": 88, "occurrences": 92, "z": 11.1 }
],
"examples": ["อ๋อไม่ได้ทานแล้วค่ะ", "ยังไม่ได้ทานเลยค่ะ เก็บไว้ก่อน"],
"volume": { "calls": 207, "occurrences": 241, "share": 0.0429 },
"distinctiveness": 19.3,
"quality": {
"avgScore": 61.4, "scored": 198, "answered": 207, "missed": 0, "avgDurationMs": 142000,
"sentiment": { "positive": 62, "neutral": 104, "negative": 32, "unscored": 9 }
},
"comparison": { "calls": 164, "changePct": 26.22 },
"worstCalls": [{ "callId": "019ff109-…", "callAt": "2026-08-12T04:11:02.000Z", "score": 21 }]
}
]
}
volume.shareหารด้วยtotalCallsคือสายที่มีเสียงลูกค้าจริง ไม่ใช่ทุกสายในช่วงนั้น เพราะในช่วงเวลาหนึ่งมีทั้งสายที่ไม่มีคนรับ สายผิด และสายที่ยังวิเคราะห์ไม่เสร็จ ถ้าหารด้วยสายทั้งหมดจะทำให้ทุกหัวข้อดูเล็กกว่าความจริง และยิ่งเพี้ยนมากในธุรกิจที่มีสายหลุดเยอะbackground.basisปกติเป็นplatform_excluding_selfหรือเป็นplatform_pooledเมื่อข้อมูลของธุรกิจอื่นน้อยเกินไปจนเทียบไม่ได้ (ติดตั้งแบบผู้เช่ารายเดียว) ระบบรายงานค่านี้ออกมาให้ ไม่ต้องเดาเองsentimentคือคะแนน 0-100 ของ AI ที่แบ่งช่วงตามจุดตัดเดียวกับหน้าภาพรวม เพราะอารมณ์รายสายอยู่ในไฟล์ผลวิเคราะห์ จึงนำมารวมกลุ่มไม่ได้ — เงื่อนไขเดียวกับ/insights/overviewworstCallsคืน id เท่านั้น ให้ไปดึงสายจาก/recordings/{id}— คำว่า "เราตอบเรื่องนี้ได้ไม่ดี" จะมีประโยชน์ก็ต่อเมื่อกดฟังสายนั้นได้- ระบบ cache คำตอบแยกตามธุรกิจ ช่วงเวลา และสิทธิ์ของผู้เรียก: 1 ชั่วโมงถ้าช่วงเวลายังไม่ปิด และ 7 วันเมื่อช่วงเวลาปิดแล้ว ดูได้จาก
x-cache: hit|miss - ถ้าช่วงเวลากว้างเกินกว่าจะคำนวณทันในเวลาที่กำหนด จะได้ 400
period_too_largeให้ย่อช่วงลง ลองใหม่ด้วยช่วงเดิมก็ไม่ช่วย
GET /insights/quality
สายในช่วงนั้นทำได้ดีแค่ไหน: การกระจายคะแนน สัดส่วนสายที่ผ่านแต่ละขั้นของการรับสาย ปัญหาไหนพบบ่อยสุด และตัวชี้ไปยังสายที่ดีที่สุด/แย่ที่สุด
| Param | Type | Note |
|---|---|---|
scoreThreshold | int 0-100 | default 70 — เกณฑ์ว่าเท่าไรถือว่าผ่าน |
sampleSize | int 1-20 | default 5 — จำนวนตัวชี้ต่อฝั่ง (response บอกเพดานไว้ที่ maxSamples) |
บวกกับพารามิเตอร์ร่วม (รวม agentKey ที่ส่งซ้ำได้)
{
"period": { "…": "เหมือนด้านบน" },
"scoreThreshold": 70,
"quality": { "avgScore": 78.4, "scored": 1096, "passed": 612, "passRate": 0.5584 },
"scoreDistribution": [
{ "bucket": "0-19", "calls": 12, "share": 0.0093 },
{ "bucket": "20-39", "calls": 84, "share": 0.0654 },
{ "bucket": "40-59", "calls": 190, "share": 0.148 },
{ "bucket": "60-79", "calls": 398, "share": 0.31 },
{ "bucket": "80-100", "calls": 412, "share": 0.3208 },
{ "bucket": "unscored", "calls": 188, "share": 0.1464 }
],
"funnel": {
"total": 1284, "answered": 1102, "real": 968, "quality": 612,
"lostMissed": 182, "lostJunk": 134, "lostLowScore": 356, "scored": 968
},
"issues": [
{ "key": "no_answer", "calls": 182, "share": 0.1417 },
{ "key": "low_score", "calls": 484, "share": 0.3769 }
],
"maxSamples": 20,
"samples": {
"best": [{ "id": "0192b9...", "score": 98, "callAt": "2026-08-14T03:22:11.000Z" }],
"worst": [{ "id": "0192c1...", "score": 11, "callAt": "2026-08-09T07:41:02.000Z" }]
},
"comparison": {
"avgScore": { "previous": 76.1, "changePct": 3.02 },
"passRate": { "previous": 0.53, "changePct": 5.36 },
"scored": { "previous": 1004, "changePct": 9.16 }
}
}
scoreDistributionคืน 6 ช่วงเดิมเรียงลำดับนี้เสมอ กราฟจึงไม่ต้องรับมือกับช่วงที่หายไป- funnel ทั้ง 4 ขั้น แต่ละขั้นเป็นเซตย่อยของขั้นก่อนหน้าเสมอ ส่วนต่างระหว่าง 2 ขั้นคือจำนวนสายที่ไม่ผ่านไปขั้นถัดไปด้วยสาเหตุนั้น:
total→answered(หักสายที่ไม่มีคนรับ) →real(หักshort/wrong_or_spam) →quality(คะแนนถึงเกณฑ์) ·funnel.scoredบอกว่าในกลุ่มrealนั้น AI ให้คะแนนไปแล้วเท่าไร — ถ้าตัวเลขนี้ต่ำแปลว่ากลุ่มตัวอย่างของขั้นคุณภาพยังน้อย ไม่ได้แปลว่าทุกคนสอบตก issuesคือ 7 สัญญาณที่เป็นอิสระต่อกัน (no_answer,short,wrong_or_spam,other,low_score,unscored,processing_failed) เรียงจากมากไปน้อย · ไม่ใช่การแบ่งกลุ่มแบบไม่ทับซ้อน — สายเดียวเป็นได้ทั้งshortและunscored— ผลรวมจึงไม่เท่ากับtotalpassRateหารด้วยจำนวนสายที่มีคะแนน ไม่ใช่ทุกสาย เพราะสายที่ยังไม่ถูกให้คะแนนไม่ใช่สายที่สอบตก และการหารด้วยยอดรวมจะกดอัตราลงทุกครั้งที่คิววิเคราะห์ทำงานไม่ทันsamplesคืนแค่ id, คะแนน และเวลาโทร · ถ้าต้องการบทถอดเสียง ให้ไปดึงต่อที่GET /recordings/detail