Callbacks

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

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 เดิมเรียกเอาเฉพาะส่วนที่ต้องการได้

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

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

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

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

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

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

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

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

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

Node

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

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

GET /callbacks

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

ParamTypeNote
levelbusiness | accountdefault business
deliveries1ใส่เพื่อขอ log การส่งมาด้วย (ถ้าไม่ใส่จะไม่ query ส่วนนี้)
limitint 1-100default 20 — ใช้เมื่อ deliveries=1
offsetintdefault 0
curl "https://phone.mcloud.co.th/api/v1/callbacks?deliveries=1&limit=5" \
  -H "Authorization: Bearer crk_..." \
  -H "X-Business-Id: 0192b7d1-5c30-7a92-9f41-6c2e83b4a105"
{
  "level": "business",
  "businessId": "0192b7d1-5c30-7a92-9f41-6c2e83b4a105",
  "callback": {
    "url": "https://hooks.example.com/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 ใน payload
  • businessName มีค่าเฉพาะบน log ระดับ account (ซึ่งรวมหลายธุรกิจ) บน log ระดับ business จะเป็น null เสมอ เพราะทุกแถวเป็นธุรกิจเดียวกันอยู่แล้ว

PUT /callbacks

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

ParamTypeNote
urlstring (สูงสุด 2048)required — ดู กติกาของ URL
enabledbooleanoptional — ไม่ส่ง = คงค่าเดิมไว้ (endpoint ที่เพิ่งสร้างเริ่มต้นเป็น true)
regenerateSecretbooleanoptional — ออก secret ใหม่และยกเลิกอันเดิมทันที
curl -X PUT https://phone.mcloud.co.th/api/v1/callbacks \
  -H "Authorization: Bearer crk_..." \
  -H "X-Business-Id: 0192b7d1-5c30-7a92-9f41-6c2e83b4a105" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/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 เงียบ ๆ — คนที่ลบผิดระดับควรได้รู้

ข้อผิดพลาด

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

ระดับ 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

เอกสาร API