Callbacks
ให้ระบบของคุณรู้ทันทีที่สายหนึ่งวิเคราะห์เสร็จ (หรือวิเคราะห์ไม่สำเร็จ) — เรา POST ไปที่ URL ของคุณพร้อมลายเซ็นที่ตรวจสอบได้ แทนที่จะต้องวน poll GET /recordings
Account key: การตั้งค่าระดับ business (
?level=businessหรือไม่ส่งlevel) ต้องส่งX-Business-Id: <business id>เหมือน endpoint อื่น ๆ (ไม่ส่ง =400 business_required) ส่วน?level=accountไม่อ่าน header นี้เลย เพราะเป็น endpoint ของพาร์ทเนอร์ ไม่ใช่ของ business ใด business หนึ่ง ดูเพิ่มที่ Getting started → API Key ระดับบัญชี
เหตุการณ์ที่ส่ง
event | ส่งเมื่อ |
|---|---|
analysis_completed | สายวิเคราะห์เสร็จสมบูรณ์ — มี transcript/สรุป และมียอดที่ถูกหักแล้ว |
analysis_failed | สายนั้นวิเคราะห์ไม่สำเร็จ และจะไม่มีผลลัพธ์ออกมาเอง |
analysis_failed เป็นของใหม่ — ก่อนหน้านี้ไม่มีอะไรบอกว่าสายหนึ่ง "วิเคราะห์ไม่สำเร็จ" เลย ระบบภายนอกจึงเห็นแค่สายที่ค้างอยู่เฉย ๆ โดยไม่รู้ว่าต้องเลิกรอ ถ้าคุณมีคิวที่รอผลอยู่ ให้ปิดงานด้วย event นี้
ทั้งสอง event มี deliveryId ของตัวเอง และถูกส่งแยกกันต่อ endpoint แต่ละตัว
ปลายทางสองระดับ — ยิงทั้งคู่ ไม่ทดแทนกัน
มี 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 เดิมเรียกเอาเฉพาะส่วนที่ต้องการได้
| ฟิลด์ | ชนิด | ความหมาย |
|---|---|---|
version | 1 | ขึ้นเลขใหม่เฉพาะตอนที่มี breaking change — ฟิลด์ใหม่ที่เพิ่มเข้ามาไม่ขึ้นเลข ให้ receiver ทนต่อคีย์ที่ไม่รู้จักไว้ |
event | analysis_completed | analysis_failed | ดูตารางด้านบน |
deliveryId | uuid | id ของใบส่งนี้ คงที่ทุกครั้งที่ retry — ใช้ dedupe |
occurredAt | ISO 8601 พร้อม offset | เวลาที่เหตุการณ์เกิด |
business.id / business.name | uuid / string | ธุรกิจเจ้าของสาย |
partner.id | uuid | มีเฉพาะ payload ที่ส่งไป endpoint ระดับ account |
call.id | uuid | id ของสายในระบบเรา — มีเสมอ ใช้ยิง GET /recordings/{id} ได้แม้ links.* จะเป็น null |
call.dataId | string | null | id ฝั่งคุณที่ส่งมาตอน ingest (ถ้าส่งมา) |
call.callAt | ISO 8601 พร้อม offset | เวลาที่โทร |
call.direction | in | out | ทิศทางสาย |
call.durationMs | int | null | ความยาวสาย |
call.customerPhone | string | null | เบอร์ลูกค้า |
call.callcenterNumber | string | null | เบอร์/คู่สายฝั่งเรา |
call.callcenterName | string | null | ชื่อ agent |
call.processingStatus | completed | failed | ตรงกับ event เสมอ (อ่านจาก event ไม่ได้อ่านซ้ำจากแถว) |
call.score | int 0-100 | null | คะแนนจาก AI — null เสมอบน analysis_failed และบนสายที่ไม่ได้ให้คะแนน |
call.tag | string | null | แท็กของสาย — null บน analysis_failed |
cost | object | null | null บน analysis_failed (ไม่มีการหักเงินสำหรับรอบที่ไม่ได้ผลลัพธ์) |
cost.chargedThb | number | ยอดบาทที่ถูกหักจริง เป็นตัวเลขเดียวกับที่ GET /recordings คืนมา |
cost.currency | "THB" | สกุลเงิน |
links.recording | string | null | GET /api/v1/recordings/{id} |
links.transcript | string | null | บทสนทนาเต็ม |
links.summary | string | null | สรุป |
error.message | string | มีเฉพาะ 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เสมอ- ถ้าธุรกิจอยู่ใต้พาร์ทเนอร์ ยอดนี้ถูกหักจากกระเป๋ารวมของพาร์ทเนอร์ ใช้ตั้งบิลต่อให้ลูกค้าของคุณเองได้
ตัวอย่าง 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"
},
"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
},
"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-Type | application/json |
X-Teletae-Signature | t=<unix seconds>,v1=<hex> — ดูหัวข้อถัดไป |
X-Teletae-Event | ชื่อ event ใช้ route ได้ก่อนแกะ body |
X-Teletae-Delivery | deliveryId ใช้ dedupe ได้โดยไม่ต้องแกะ body |
ตรวจสอบลายเซ็น
X-Teletae-Signature: t=1758000000,v1=9f86d081884c7d659a2feaa0c55ad015...
ลายเซ็นคือ HMAC-SHA256 เป็น hex ตัวพิมพ์เล็ก ของสตริง "<t>.<raw body>" โดยใช้ signing secret (ทั้งสตริงรวม whsec_ ด้วย — คำนำหน้าเป็นส่วนหนึ่งของค่า ไม่ใช่ป้ายที่ต้องตัดทิ้ง) เป็นกุญแจ
กติกาสามข้อที่พลาดกันบ่อยที่สุด — พลาดข้อไหนก็ตรวจไม่ผ่านหรือตรวจไม่ปลอดภัย:
- ใช้ raw body เป็น bytes อย่าใช้ JSON ที่ parse แล้ว serialise ใหม่ — ลำดับคีย์และช่องว่างเปลี่ยน ลายเซ็นก็เปลี่ยน (framework ส่วนใหญ่ต้องสั่งเป็นพิเศษถึงจะได้ body ดิบ)
- เทียบแบบ constant-time (
crypto.timingSafeEqual,hmac.compare_digest) การเทียบสตริงด้วย===รั่วข้อมูลผ่านเวลาที่ใช้เทียบ - ปฏิเสธใบที่เก่าเกินหน้าต่างเวลา แนะนำ 300 วินาที — นี่คือเหตุผลที่
tถูกใส่ไว้ในสตริงที่เซ็น ถ้าเซ็นแค่ body ใครก็ตามที่เคยเห็น request ที่ถูกต้องหนึ่งใบจะ replay ซ้ำได้ตลอดกาล
Node
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.TELETAE_CALLBACK_SECRET // 'whsec_...'
const TOLERANCE_SEC = 300
// express.raw() ไม่ใช่ express.json() — ต้องได้ body ดิบมาคำนวณลายเซ็น
app.post('/teletae/callback', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('X-Teletae-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["TELETAE_CALLBACK_SECRET"] # 'whsec_...'
TOLERANCE_SEC = 300
@app.post("/teletae/callback")
def teletae_callback():
header = request.headers.get("X-Teletae-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ที่เคยรับแล้วไว้ แล้วทิ้งใบซ้ำ — จำเป็นจริง เพราะปลายทางที่รับสำเร็จแต่ตอบกลับช้าจนหมดเวลาจะได้รับใบเดิมอีกครั้ง
รายการเหตุผลที่ล้มเหลว
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
endpoint เดียว สามคำกริยา และ ?level= เป็นตัวบอกว่าหมายถึง endpoint ตัวไหน
?level= | หมายถึง | เลือก business อย่างไร |
|---|---|---|
ไม่ส่ง หรือ business | endpoint ของธุรกิจ | business key = ธุรกิจของตัวเอง · account key = ธุรกิจที่ระบุใน X-Business-Id |
account | endpoint ระดับพาร์ทเนอร์ของผู้เรียก | account key เท่านั้น — business key ได้ 403 account_key_required และ X-Business-Id ไม่ถูกอ่านเลย |
| ค่าอื่น | — | 400 invalid_level (ไม่ถอยไปใช้ business ให้ — ?level=acount ที่พิมพ์ผิดต้องไม่ไปเขียนทับ endpoint ของธุรกิจเงียบ ๆ) |
GET /callbacks
อ่านค่าที่ตั้งไว้ และ (ถ้าขอ) log การส่ง
| Param | Type | Note |
|---|---|---|
level | business | account | default business |
deliveries | 1 | ใส่เพื่อขอ log การส่งมาด้วย (ถ้าไม่ใส่จะไม่ query ส่วนนี้) |
limit | int 1-100 | default 20 — ใช้เมื่อ deliveries=1 |
offset | int | default 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/teletae",
"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ใน payloadbusinessNameมีค่าเฉพาะบน log ระดับ account (ซึ่งรวมหลายธุรกิจ) บน log ระดับ business จะเป็นnullเสมอ เพราะทุกแถวเป็นธุรกิจเดียวกันอยู่แล้ว
PUT /callbacks
สร้างหรือแก้ endpoint
| Param | Type | Note |
|---|---|---|
url | string (สูงสุด 2048) | required — ดู กติกาของ URL |
enabled | boolean | optional — ไม่ส่ง = คงค่าเดิมไว้ (endpoint ที่เพิ่งสร้างเริ่มต้นเป็น true) |
regenerateSecret | boolean | optional — ออก 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/teletae"}'
{
"level": "business",
"businessId": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105",
"callback": {
"url": "https://hooks.example.com/teletae",
"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 เก่าจะตรวจไม่ผ่านทันที วางแผนให้ receiver รับได้สองค่าชั่วคราวระหว่าง deploy จะเจ็บน้อยที่สุด
การแก้เฉพาะ 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 เงียบ ๆ — คนที่ลบผิดระดับควรได้รู้
ข้อผิดพลาด
code | HTTP | เมื่อไหร่ |
|---|---|---|
account_key_required | 403 | business key เรียก ?level=account |
insufficient_scope | 403 | token ไม่มี scope manage_api_tokens |
invalid_level | 400 | ?level= เป็นค่าอื่นนอกจาก business / account |
partner_not_found | 404 | ?level=account แต่เจ้าของ key ไม่ได้เป็นเจ้าของพูลพาร์ทเนอร์ |
callback_not_found | 404 | DELETE ทั้งที่ระดับนั้นยังไม่มี endpoint |
invalid_payload | 422 | body ของ PUT ไม่ใช่ JSON object |
callback_url_required | 422 | url ว่าง |
callback_url_too_long | 422 | url ยาวเกิน 2048 ตัวอักษร |
callback_url_invalid | 422 | url ไม่ใช่ URL ที่ parse ได้ |
callback_url_not_https | 422 | ไม่ใช่ https:// |
callback_url_has_credentials | 422 | มี user/password อยู่ใน URL |
callback_url_private_host | 422 | ชี้ไปโฮสต์ภายใน / loopback / link-local |
ระดับ business ด้วย 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
