เอกสารที่เกี่ยวข้อง: ตัวอย่างโค้ดรันได้จริง (NestJS) อยู่ที่ examples/api-channel-nestjs-relay/และเอกสารส่งมอบสั้น ๆ สำหรับทีมเชื่อมต่อที่api-channel-handoff.th.md
ระบบภายนอกของคุณ
│
│ POST /api/v1/channels/{channelId}/messages (ขาเข้า — คุณส่ง)
│ Authorization: Bearer sk_…
▼
OneBear API ──▶ คอนโซลเจ้าหน้าที่ (บทสนทนาใหม่หรือที่มีอยู่)
│ auto-reply / AI chatbot / มอบหมายงาน / แท็ก
│
│ POST https://webhook-ของคุณ (ขาออก — เราส่ง)
│ X-OneBear-Signature: t=…,v1=…
▼
ระบบภายนอกของคุณ ──▶ ส่งการตอบกลับให้ผู้ใช้ปลายทางผู้ใช้ LINE ส่งข้อความ
└─▶ LINE webhook ของคุณ
└─▶ POST /api/v1/channels/{channelId}/messages externalUserId = LINE userId
└─▶ OneBear: สร้างบทสนทนา, รัน AI / auto-reply
└─▶ POST https://webhook-ของคุณ event:"message"
└─▶ เรียก LINE push API ไปที่ userId นั้นclientMessageId (ภายใน 5 นาที) ดังนั้นสามารถส่งซ้ำเมื่อเกิดข้อผิดพลาดทางเครือข่ายได้อย่างปลอดภัยsenderType: "agent" (มนุษย์) หรือ "bot" (AI) เพื่อให้คุณรู้ว่าต้องปิด bot ของตัวเองหรือไม่event:"handoff" เพื่อให้ relay ของคุณหยุดส่งต่อ bot| ฟิลด์ | ทำอะไร |
|---|---|
| ชื่อ (Name) | ป้ายภายในที่แสดงในกล่องข้อความของคอนโซล |
| Outbound webhook URL | Endpoint HTTPS ที่ OneBear จะ POST การตอบกลับของเจ้าหน้าที่มาให้ ต้องเข้าถึงได้จากอินเทอร์เน็ต |
| ค่า | รูปแบบ | แสดงเมื่อ |
|---|---|---|
Channel ID (channelId) | ac_… | ตลอดเวลา — เห็นได้ที่หน้ารายละเอียด |
| API key | sk_… | ครั้งเดียวเท่านั้น — คัดลอกตอนนี้เลย |
| Signing secret | whsec_… | ครั้งเดียวเท่านั้น — คัดลอกตอนนี้เลย |
คัดลอก API key และ signing secret ก่อนปิด dialog นี้ ค่าเหล่านี้จัดเก็บแบบ hash และจะไม่แสดงอีก สามารถ rotate ทีหลังได้ — การ rotate จะสร้างค่าใหม่และเพิกถอนค่าเดิมทันที
Authorization: Bearer sk_your_api_keychannelId ใน URL ก็ถูกตรวจสอบกับ key ด้วย — key ของช่องทาง A ไม่สามารถเข้าถึงช่องทาง B ได้แม้อยู่ในบริษัทเดียวกันPOST /api/v1/channels/{channelId}/messages
Authorization: Bearer sk_…
Content-Type: application/json{
"externalUserId": "U1234567890abcdef",
"displayName": "สมหญิง",
"pictureUrl": "https://cdn.example.com/u1.jpg",
"content": "สวัสดีครับ ต้องการความช่วยเหลือเรื่องคำสั่งซื้อ",
"messageType": "text",
"clientMessageId": "unique-idempotency-key-ของคุณ",
"timestamp": 1720000000000,
"quoteToken": "qt_abc",
"replyToPlatformMessageId": "unique-idempotency-key-ก่อนหน้า"
}| ฟิลด์ | ชนิด | บังคับ | คำอธิบาย |
|---|---|---|---|
externalUserId | string | ใช่ | ตัวระบุที่คงที่ของผู้ใช้ปลายทางในระบบของคุณ (เช่น LINE userId, visitor id) OneBear สร้าง chat user หนึ่งคนต่อ externalUserId + ช่องทาง |
displayName | string | ไม่ | ชื่อที่แสดงในคอนโซลสำหรับผู้ใช้ใหม่ ไม่มีผลถ้าผู้ใช้มีอยู่แล้ว |
pictureUrl | string | ไม่ | URL รูปโปรไฟล์ — absolute http/https เท่านั้น (≤2048) ต้องเปิดสาธารณะ merge เฉพาะตอนค่าเปลี่ยน ส่งมาทุกข้อความได้ |
content | string | เงื่อนไข | ข้อความ บังคับถ้าไม่มี attachmentId |
messageType | string | ไม่ | "text" (ค่าเริ่มต้น), "image", หรือ "file" เมื่อมี attachmentId ระบบจะกำหนด type จาก content-type ของไฟล์เอง |
attachmentId | string | ไม่ | ID ที่ได้จาก upload endpoint ส่งแทน (หรือพร้อมกับ) content เพื่อส่งไฟล์ |
clientMessageId | string | ใช่ | Idempotency key ที่คุณกำหนดเอง ค่าซ้ำภายใน 5 นาทีจะคืนผลเดิมโดยไม่สร้างข้อความซ้ำ |
timestamp | integer | ไม่ | Unix millisecond timestamp (เวลา client) ถ้าไม่ใส่จะใช้เวลาเซิร์ฟเวอร์ |
quoteToken | string | ไม่ | handle opaque ของข้อความนี้ (≤256) OneBear เก็บไว้ดิบๆ แล้วส่งกลับใน webhook ขาออกเมื่อเจ้าหน้าที่ quote ข้อความนี้ |
replyToPlatformMessageId | string | ไม่ | ตั้งเมื่อข้อความนี้ quote ข้อความเก่า: ใส่ clientMessageId ของข้อความที่ถูก quote |
pictureUrl)<img src> ตรงๆ (useAvatarSrc proxy ให้เฉพาะไฟล์ที่อยู่ใน blob storage ของ OneBear เอง) ดังนั้น URL ต้อง เปิดสาธารณะ ไม่ต้อง auth ไม่กัน hotlink — CDN ของ LINE ใช้ได้เลยChatUserService.UpsertExternalUserAsync merge เฉพาะค่าที่ไม่ null และเขียน DB เฉพาะตอนค่าเปลี่ยนจริง → ส่ง pictureUrl มาทุกข้อความได้ ไม่เปลือง write และรูปอัปเดตเองเมื่อผู้ใช้เปลี่ยนรูปreplyToPlatformMessageId = clientMessageId ของข้อความที่ถูก quote → คอนโซลแสดงข้อความที่อ้างอิงเหนือข้อความใหม่quoteToken มาทุกข้อความ → OneBear ผูกไว้กับข้อความนั้นและส่งสตริงเดิมกลับมาใน webhook ขาออกตอนเจ้าหน้าที่กด quote → ไม่ต้องทำตาราง map id เองquoteToken เป็นค่า opaque สำหรับ OneBear — ใช้อะไรก็ได้ที่คงที่และเรียกซ้ำได้ ถ้าเป็น relay ที่คั่นหน้า LINE ให้ส่ง quoteToken ของ LINE ผ่านมาตรงๆreplyToPlatformMessageId ต้องเป็น id ที่ OneBear รู้จัก ซึ่งสำหรับข้อความขาออกคือ id ชั่วคราวที่ OneBear ตั้งเอง (= messageId ใน webhook) ไม่ใช่ id บนแพลตฟอร์มของคุณ แจ้ง id จริงกลับได้ที่ POST /api/v1/channels/{channelId}/messages/{messageId}/platform-id body { "externalUserId": "...", "platformMessageId": "..." } — ทั้งสองค่ามาจาก payload ของ webhook ไม่ต้องเก็บ state (best-effort, idempotent; มีอีกรูปแบบที่ระบุ conversationId ใน path ถ้าเก็บไว้อยู่แล้ว) หรือเก็บ map เองแล้วส่ง messageId ของ OneBear มาแทน200 + Content-Type: application/json + body { "platformMessageId": "..." } OneBear จะเก็บให้ทันทีโดยไม่ต้องเรียก endpoint นี้เลย (body ที่อ่านไม่ได้จะถูกข้ามเงียบๆ ไม่ถือเป็น error)⚠️ อย่าส่ง replyTokenของ LINE — หมดอายุ ~30 วินาที ใช้ได้ครั้งเดียว จึงไม่รอด round trip ผ่าน OneBearquoteTokenของ LINE ไม่หมดอายุและใช้ซ้ำได้ — ตัวนี้คือตัวที่ต้องใช้
{
"conversationId": "room_abc123",
"messageId": "msg_xyz789",
"isNewConversation": true,
"replyToResolved": true
}replyToResolved: true = อ้างอิง quote ติด, false = หาไม่เจอจึงตัด quote ทิ้ง (ข้อความยังส่งถึงปกติ), ไม่มีค่า = ไม่ได้ส่ง replyToPlatformMessageId มา — ใช้ debug ได้โดยไม่ต้องเปิด log400 — ฟิลด์บังคับหายไป รูปแบบไม่ถูกต้อง หรือไม่พบ attachment403 — channelId ใน URL ไม่ตรงกับ key ที่ยื นยันตัวตน409 — ETag conflict (การอัปเดตพร้อมกันบนบทสนทนาเดียว — ลองใหม่ได้)429 — เกิน rate limit502 — ปลายทางมีปัญหาชั่วคราว (ลองใหม่ได้)POST /api/v1/channels/{channelId}/attachments
Authorization: Bearer sk_…
Content-Type: multipart/form-datafile (ไฟล์ binary)image/png, image/jpeg, image/gif, image/webp, application/pdf{
"id": "att_abc123",
"fileName": "ใบเสร็จ.pdf",
"contentType": "application/pdf",
"size": 204800
}400 ไม่มีไฟล์, 413 ไฟล์ใหญ่เกิน, 415 ชนิดไฟล์ไม่รองรับPOST /api/v1/channels/{channelId}/messages
{
"externalUserId": "U1234567890",
"attachmentId": "att_abc123",
"clientMessageId": "msg-001"
}messageType จะถูกกำหนดอัตโนมัติจาก content-type ของ attachment ("image" สำหรับรูปภาพ, "file" สำหรับ PDF และอื่นๆ)POST https://webhook-ของคุณ.example.com/hook
Content-Type: application/json
X-OneBear-Signature: t=1720000000,v1=abc123def456…event: "message"):{
"event": "message",
"externalUserId": "U1234567890abcdef",
"messageId": "msg_xyz789",
"content": "สวัสดีครับ คำสั่งซื้อ #1234 ถูกจัดส่งแล้ว!",
"messageType": "text",
"senderType": "agent",
"senderName": "สมชาย ใจดี",
"senderAvatarUrl": "https://…/member-avatars/…/abc.png",
"mediaUrl": null,
"fileName": null,
"timestamp": 1720000100000
}event: "handoff"):{
"event": "handoff",
"conversationId": "room_abc123",
"externalUserId": "U1234567890abcdef",
"reason": "complex_query",
"timestamp": 1720000200000
}| ฟิลด์ | คำอธิบาย |
|---|---|
event | "message" — การตอบกลับจากเจ้าหน้าที่/AI; "handoff" — AI ส่งต่อให้มนุษย์ |
externalUserId | ID เดียวกับที่คุณส่งเข้ามา ใช้นี้เพื่อ route การตอบกลับไปหาผู้ใช้ที่ถูกต้อง |
conversationId | Room id ของ OneBear มีบน handoff event; ไม่มีบน message (ให้ correlate ด้วย externalUserId) |
messageId | มีบน message event ใช้ดึง media ถ้า mediaUrl มีค่า |
content | ข้อความ อาจเป็น null สำหรับข้อความที่มีแค่สื่อ |
messageType | "text" | "image" | "file" |
senderType | "agent" (มนุษย์) | "bot" (AI chatbot) ใช้เพื่อตัดสินใจว่าจะระบุป้ายกำกับการตอบกลับหรือไม่ |
senderName | ชื่อที่แสดงของเจ้าหน้าที่ที่ตอบ เป็น null เมื่อเป็นการตอบของ "bot" และเมื่อเจ้าหน้าที่ไม่มีชื่อในระบบ |
senderAvatarUrl | URL เต็มของรูปโปรไฟล์เจ้าหน้าที่คนนั้น ดึงได้โดยไม่ต้องใช้ Bearer key เป็น null เมื่อเป็น "bot" หรือไม่มีรูป |
mediaUrl | URL สัมพัทธ์สำหรับดึงสื่อ: /api/v1/channels/{channelId}/media/{messageId} ใช้ Bearer key ของคุณดาวน์โหลด |
fileName | ชื่อไฟล์เดิมสำหรับ file attachment |
quoteToken | มีเฉพาะตอนเจ้าหน้าที่/AI quote ข้อความลูกค้า: คือ quoteToken ตัวเดิมที่คุณส่งมาตอน ingest ข้อความนั้น |
timestamp | Unix millisecond timestamp |
GET /api/v1/channels/{channelId}/media/{messageId}
Authorization: Bearer sk_…Content-Type header ที่เหมาะสม endpoint บังคับ IDOR guard — key สามารถดึง media จากบทสนทนาของช่องทางตัวเองเท่านั้นX-OneBear-Signature: t={unix_seconds},v1={lowercase_hex_hmac}HMAC-SHA256(key=signingSecret, message="{t}.{rawBody}") โดย rawBody คือ raw request body bytes เป็น string และ t คือ unix timestamp seconds จาก headert เก่าเกิน 5 นาที (ป้องกัน replay attack)สำคัญ: อ่าน raw request body bytes เสมอก่อน parse JSON เพราะ parser อาจ normalize whitespace ซึ่งจะทำให้ HMAC เปลี่ยน
POST /api/v1/channels/{channelId}/handoff
Authorization: Bearer sk_…
Content-Type: application/json
{
"externalUserId": "U1234567890abcdef",
"reason": "complex_query",
"note": "ลูกค้าบอกว่าสินค้ายังไม่ถึงหลังจากรอ 3 สัปดาห์"
}POST /api/v1/channels/{channelId}/conversations/{conversationId}/handoff
Authorization: Bearer sk_…
Content-Type: application/json
{
"reason": "human_requested"
}| ฟิลด์ | บังคับ | คำอธิบาย |
|---|---|---|
externalUserId | เงื่อนไข | บังคับถ้าไม่มี conversationId |
reason | ไม่ | ป้ายกำกับเหตุผล (เช่น "complex_query", "human_requested") ค่าเริ่มต้น: "human_requested" |
note | ไม่ | หมายเหตุยาวขึ้นที่แสดงให้เจ้าหน้าที่เห็น |
{
"conversationId": "room_abc123",
"isAiMuted": true,
"handoffSource": "api"
}event:"handoff" มายัง outbound webhook ของคุณ ใช้ event นี้เพื่อปิด bot ของ relay ของคุณGET /api/v1/channels/{channelId}/conversations/{conversationId}/messages?pageSize=50&continuationToken=
Authorization: Bearer sk_…continuationToken จาก response เพื่อดึงหน้าถัดไป| ค่า | ระดับความลับ | ปรากฏที่ไหนได้ |
|---|---|---|
Channel ID ac_… | ไม่ใช่ความลับ | config, log, URL — ได้ |
API key sk_… | ลับ (ระดับรหัสผ่าน) | Environment variable / secret manager เท่านั้น ห้ามอยู่ใน source code หรือ client-side |
Signing secret whsec_… | ลับ (ระดับรหัสผ่าน) | Webhook handler ของคุณเท่านั้น ห้ามอยู่ใน source code หรือ client-side |
externalUserId = LINE userId — เป็นตัวระบุที่คงที่ที่เชื่อมทั้งสองทิศทางclientMessageId เพื่อให้ retry ปลอดภัยsenderType เพื่อระบุป้ายกำกับข้อความว่าเป็น "เจ้าหน้าที่" หรือ "บอต" ใน LINEquoteToken ของ LINE (ไม่ใช่ replyToken) เพื่อให้เจ้าหน้าที่ quote-reply ได้ — OneBear ส่งกลับมาให้เหมือนเดิมhandoff ให้หยุด bot ของ relay ไม่ให้ส่งข้อความที่ขับเคลื่อนด้วย bot เพิ่มเติมไปหาผู้ใช้คนนั้น| อาการ | สาเหตุที่เป็นไปได้ / วิธีแก้ |
|---|---|
401 Unauthorized | ขาด header Authorization: Bearer sk_… หรือ key ผิด ตรว จว่า key ยังไม่ถูก rotate |
403 Forbidden | channelId ใน URL ไม่ตรงกับช่องทางที่ key ระบุ ตรวจสอบ URL |
400 VALIDATION | ขาด externalUserId หรือ clientMessageId หรือ messageType ไม่ถูกต้อง ดูฟิลด์ error ใน response |
400 ATTACHMENT_NOT_FOUND | attachmentId ไม่มีอยู่หรืออยู่ในบริษัทอื่น อัปโหลดใหม่ด้วย key เดียวกัน |
413 ตอน upload | ไฟล์เกิน 10 MB บีบอัดหรือแยกไฟล์ก่อนอัปโหลด |
415 ตอน upload | ชนิด content type ไม่อยู่ใน allowlist ที่รับ: PNG, JPEG, GIF, WebP, PDF |
| ไม่ได้รับ webhook | Outbound URL ต้องเป็น HTTPS endpoint สาธารณะ ตรวจว่าเข้าถึงได้จากอินเทอร์เน็ต ตรวจสอบว่า URL บันทึกถูกต้องในคอนโซล |
| ตรวจสอบ signature ล้มเหลว | ส่ง raw request bytes (ก่อน parse JSON) ไปยัง HMAC ตรวจว่าใช้ signing secret (whsec_…) ไม่ใช่ API key |
| สร้างข้อความซ้ำ | ใส่ clientMessageId (id ที่ไม่ซ้ำและคงที่ต่อ message event หนึ่งครั้ง) ข้อความซ้ำภายใน 5 นาทีจะถูก de-dup เงียบๆ |
| API key เก่าถูกปฏิเสธหลัง rotate | key เก่าถูกเพิกถอนทันทีเมื่อ rotate อัปเดต ONEBEAR_API_KEY ใน config แล้ว redeploy |
channelId, API key และ signing secret แยกกัน พร้อม inbox แยกในคอนโซลexternalUserId เดียวกันข้ามช่องทางต่างกันได้ไหม? User ถูก scope ต่อช่องทาง ค่า externalUserId เดียวกันในสองช่องทางจะสร้าง chat user และบทสนทนาแยกกัน5xx, 429 หรือ network/timeout (ตัวส่ง webhook ตั้ง timeout 10 วินาที + มี circuit breaker) ส่วน 4xx ถือว่าเป็น terminal ไม่ retry เนื่องจาก retry อาจทำให้ได้ event เดิมซ้ำ ให้ทำ handler เป็น idempotent โดย de-duplicate ด้วย messageId และ return 200–299 อย่างรวดเร็ว (ภายใน 10 วินาที) งานหนัก ๆ ให้ทำแบบ asyncevent:"handoff" บน webhook ของคุณcurl POST ข้อความทดสอบ