นักพัฒนาระบบ · OmniChat

เชื่อมแชทบอทเข้ากับแอปมือถือ

ทำหน้าจอแชทของแอปเอง (native) แล้วให้บอท AI และทีมงานตอบผ่าน OmniChat โดยใช้ช่องทางเว็บ (Web channel) ช่องเดียวกับวิดเจ็ตบนเว็บไซต์ ข้อความทุกข้อความเข้ากล่องแชทรวมของทีมเหมือนช่องทางอื่น
Base URL https://api.agenticos.tech/api/v1/omnichat

1. ภาพรวม

แอปมือถือ
ส่ง / poll ข้อความ →
AgenticOS API
→ บอท AI / ทีมงาน
webhook ลงลายเซ็น → เซิร์ฟเวอร์ของแอป
push FCM / APNs → แอปมือถือ

ทุก endpoint ของแอปเป็น public ยืนยันสิทธิ์ด้วย embed token อย่างเดียว ไม่ต้องมี JWT มีค่าที่ต้องใช้ 3 ตัว แยกหน้าที่กัน:

ค่าได้จากไหนเก็บไว้ที่
Embed tokenOmniChat → ช่องทาง → ช่องทางเว็บ → โค้ดฝัง (ค่า data-omnichat-token)ในแอปได้ (ค่าสาธารณะ)
Identity keyหน้าเดียวกัน → "เชื่อมกับระบบสมาชิกของเว็บคุณ" (ไม่บังคับ)เซิร์ฟเวอร์ของแอปเท่านั้น
Webhook signing keyหน้าเดียวกัน → "แจ้งเตือนในแอปมือถือเมื่อมีคำตอบ" (ไม่บังคับ)เซิร์ฟเวอร์ของแอปเท่านั้น

ก่อนเริ่ม: ต้องขออะไรจากแพลตฟอร์ม

ค่าและการตั้งค่าด้านล่างอยู่ในบัญชี AgenticOS ขององค์กรลูกค้า คนที่ให้ได้คือแอดมินขององค์กร (owner หรือ admin) ขอให้ครบก่อนเริ่มเขียนโค้ด

ต้องขอใช้ทำอะไรแอดมินหาได้ที่จำเป็น
ช่องทางเว็บที่ตั้งบอทแล้วให้มีบอทตอบ: ช่องทางต้องผูกกับทีม (desk) ที่มี AI agent เปิดตอบอัตโนมัติ และ agent ผูกคลังความรู้แล้วOmniChat → ช่องทาง → เพิ่มช่องทาง → เว็บไซต์จำเป็น
Embed tokenใส่ในทุกคำขอจากแอปช่องทางเว็บ → โค้ดฝัง → ค่า data-omnichat-tokenจำเป็น
Identity keyผูกแชทกับบัญชีสมาชิกในแอป (ข้อ 2.2)ช่องทางเว็บ → เชื่อมกับระบบสมาชิกของเว็บคุณ → สร้างกุญแจถ้าแอปมีระบบสมาชิก
Webhook signing keyตรวจลายเซ็น webhook สำหรับ push (ข้อ 4)ช่องทางเว็บ → แจ้งเตือนในแอปมือถือเมื่อมีคำตอบ → ใส่ URL ที่ dev ส่งให้ แล้วคัดลอกกุญแจถ้าต้องการ push
เพดานข้อความต่อนาทีค่าเริ่มต้น 20 ต่อ token ต่อ IP ถ้าผู้ใช้หลายคนออกเน็ตผ่าน IP เดียวกัน (เช่น Wi-Fi สำนักงาน) อาจต้องเพิ่มตั้งค่า OmniChat → ป้องกันแชทหน้าเว็บ → จำกัดอัตราข้อความถ้าจำเป็น
ฟอร์มก่อนแชท เปิดหรือปิดถ้าเปิด แอปต้องทำหน้าฟอร์ม (ข้อ 3.5)ช่องทางเว็บ → บังคับกรอกข้อมูลติดต่อก่อนแชทต้องรู้
Identity key และ signing key เป็นความลับ ให้แอดมินส่งผ่านช่องทางที่ปลอดภัย (เช่น password manager) ห้ามส่งในแชทกลุ่มหรืออีเมลทั่วไป และให้เก็บบนเซิร์ฟเวอร์เท่านั้น ส่วน embed token เป็นค่าสาธารณะ ส่งตามปกติได้
ข้อความขอข้อมูล (คัดลอกไปส่งได้)
สวัสดีครับ ทีมพัฒนาแอปจะเชื่อมแชทบอท AgenticOS เข้ากับแอปมือถือ รบกวนขอข้อมูลดังนี้ครับ
1. Embed token ของช่องทางเว็บที่ตั้งบอทตอบไว้แล้ว (OmniChat → ช่องทาง → เว็บไซต์ → โค้ดฝัง)
2. Identity key (ช่องทางเว็บ → เชื่อมกับระบบสมาชิกของเว็บคุณ → สร้างกุญแจ) — ส่งผ่าน password manager
3. ใส่ Webhook URL นี้: https://<เซิร์ฟเวอร์ของเรา>/omnichat/reply แล้วส่ง signing key กลับมา — ส่งผ่าน password manager
4. ฟอร์มก่อนแชทเปิดอยู่หรือไม่ และขอเพิ่มเพดานข้อความเป็น __ ครั้ง/นาที (ถ้าจำเป็น)

ทางเลือก: ใช้ WebView (ไม่ต้องทำหน้าแชทเอง)

ถ้ายังไม่ต้องการหน้าแชท native เปิดหน้าแชทสำเร็จรูปของ AgenticOS ใน WebView ได้เลย ได้ทุกฟีเจอร์ของแชทหน้าเว็บทันที (บอทตอบจากคลังความรู้ ปุ่มคำถามแนะนำ ส่งรูป ฟอร์มก่อนแชท การ์ดสินค้า ลิงก์ชำระเงิน) มี 2 แบบ

แบบเปิดอะไรผูกสมาชิกเหมาะกับ
A · หน้าแชทเต็มจอhttps://app.agenticos.tech/chat/<embed token>ไม่ได้ ทุกคนเป็นผู้ใช้ไม่ระบุตัวตนแอปที่ไม่มีระบบสมาชิก หรืออยากลองเร็วที่สุด
B · หน้า HTML ของคุณ + วิดเจ็ตแบบ inlineหน้าเล็ก ๆ ที่เซิร์ฟเวอร์ของคุณสร้าง ใส่ user_id และ user_hash ของผู้ใช้ได้ แชทตามผู้ใช้ทุกเครื่อง และใช้ push ได้แอปที่มีระบบสมาชิก (แนะนำ)

แบบ B: หน้า HTML ที่เซิร์ฟเวอร์ของคุณส่งให้ WebView

เซิร์ฟเวอร์สร้างหน้านี้ต่อผู้ใช้หนึ่งคน โดยคำนวณ user_hash บนเซิร์ฟเวอร์ (ข้อ 2.2) แล้วแอปเปิด URL ของหน้านี้ใน WebView CSS ในหน้าทำให้แชทเต็มจอ (วิดเจ็ตแบบ inline สูง 480px เป็นค่าเริ่มต้น)

HTML
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    html, body { margin: 0; height: 100%; }
    #omnichat-chat { height: 100%; }
    .ocw-inline { height: 100% !important; border: 0 !important; border-radius: 0 !important; }
  </style>
</head>
<body>
  <div id="omnichat-chat"></div>
  <script src="https://app.agenticos.tech/widget.js"
          data-omnichat-token="EMBED_TOKEN"
          data-omnichat-inline="omnichat-chat"
          data-omnichat-user-id="{{ user.id }}"
          data-omnichat-user-hash="{{ user_hash }}"
          data-omnichat-user-name="{{ user.name }}"
          async></script>
</body>
</html>
  • ผู้ใช้ออกจากระบบในแอป: เรียก omnichatEndSession() ในหน้า (เช่น evaluateJavascript) หรือล้างข้อมูล WebView ก่อนให้คนอื่นล็อกอินบนเครื่องเดียวกัน
  • push ใช้ได้กับแบบ B เท่านั้น เพราะ webhook บอก user_id ให้เซิร์ฟเวอร์หาเครื่องของผู้ใช้ได้ แบบ A ไม่มีตัวตนให้ส่ง push

ตั้งค่า WebView ให้แชททำงานครบ

เรื่องiOS (WKWebView)Android (WebView)
JavaScript และ localStorage (เก็บ session ของแชท)เปิดอยู่แล้ว ใช้ WKWebsiteDataStore.default() เพื่อให้จำ session ข้ามการเปิดแอปjavaScriptEnabled = true · domStorageEnabled = true
ส่งรูป/ไฟล์รองรับ input file เอง ใส่ NSPhotoLibraryUsageDescription และ NSCameraUsageDescription ใน Info.plistต้องทำ onShowFileChooser ใน WebChromeClient
ลิงก์ชำระเงิน ไฟล์แนบ และลิงก์ภายนอก (เปิดแบบ _blank)ทำ createWebViewWith ใน WKUIDelegate แล้วเปิดด้วย UIApplication.shared.openทำ shouldOverrideUrlLoading: host ที่ไม่ใช่หน้าแชท ให้เปิดใน browser ภายนอก
คีย์บอร์ดบังช่องพิมพ์ใช้ safe area ตามปกติandroid:windowSoftInputMode="adjustResize"
Swift
// iOS — open _blank links (payment, attachments) outside the chat
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
             for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
    if let url = navigationAction.request.url { UIApplication.shared.open(url) }
    return nil
}
Kotlin
// Android
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
        val host = req.url.host ?: return false
        if (host.endsWith("agenticos.tech")) return false          // stay in the chat
        startActivity(Intent(Intent.ACTION_VIEW, req.url)); return true
    }
}
webView.webChromeClient = object : WebChromeClient() {
    override fun onShowFileChooser(view: WebView, cb: ValueCallback<Array<Uri>>,
                                   params: FileChooserParams): Boolean {
        filePathCallback = cb
        fileChooser.launch(params.createIntent())   // registerForActivityResult(StartActivityForResult)
        return true
    }
}
webView.loadUrl(chatPageUrl)

2. ตัวตนของผู้ใช้ในแชท

2.1 แบบไม่ระบุตัวตน

แอปสร้าง session_id ครั้งเดียวต่อการติดตั้ง แล้วเก็บใน Keychain หรือ EncryptedSharedPreferences

  • สุ่มด้วยตัวสุ่มที่ปลอดภัย (CSPRNG) เพราะ session_id เป็นสิ่งเดียวที่ปกป้องประวัติแชทแบบไม่ระบุตัวตน
  • ยาว 8–128 ตัวอักษร และห้ามขึ้นต้นด้วย usr: (สงวนไว้ ส่งมาจะได้ 400 reserved_session_id)
  • รูปแบบที่วิดเจ็ตเว็บใช้ wsx- ตามด้วย hex 32 ตัว เช่น wsx-1ecb99647586f8100cb4b7ca25d51ac9
  • ข้อเสีย: ลบแอปหรือเปลี่ยนเครื่อง จะเริ่มบทสนทนาใหม่

2.2 ผูกกับบัญชีสมาชิก (แนะนำ)

ส่ง user_id และ user_hash ไปด้วย บทสนทนาจะผูกกับสมาชิก ตามไปทุกเครื่อง และทีมงานเห็นชื่อจริง

สูตร
user_hash = hex( HMAC-SHA256( key = identity key, message = user_id ) )   // lowercase hex, 64 chars
  • คำนวณบนเซิร์ฟเวอร์ของแอปเท่านั้น แอปขอ user_hash จาก backend ของตัวเองหลังล็อกอิน ห้ามฝัง identity key ในแอป
  • user_hash ผิด ไม่มี หรือช่องทางยังไม่เปิด identity: ระบบไม่ error แต่ถือเป็นผู้ใช้ไม่ระบุตัวตน และคุยใน thread ของ session_id แทน
  • ยังต้องส่ง session_id ทุกครั้งเหมือนเดิม
ต้องส่ง user_id + user_hash ชุดเดียวกันทั้งตอนส่งข้อความ อัปโหลด และ poll ถ้า poll ไม่ส่ง จะอ่านได้แต่ thread แบบไม่ระบุตัวตน และไม่เห็นคำตอบใน thread ของสมาชิก
Node.js
const userHash = crypto.createHmac("sha256", process.env.OMNICHAT_IDENTITY_KEY)
  .update(String(user.id)).digest("hex");
Python
user_hash = hmac.new(IDENTITY_KEY.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
PHP
$userHash = hash_hmac('sha256', (string)$user->id, OMNICHAT_IDENTITY_KEY);

3. Endpoint

ทุก endpoint ใช้ชื่อสำรอง /web/... แทน /webhooks/web/... ได้

GET/webhooks/web/config

ข้อมูลหน้าจอแชท เรียกครั้งเดียวตอนเปิดหน้าแชท ด้วย ?token=<embed token>

200
{
  "name": "Website chat",
  "avatarUrl": "https://…/avatar.png",
  "style": { "color": "#6366f1", "color2": "#22d3ee", "gradient": true, "title": "…", "placeholder": "…" },
  "prechat": null,
  "chatUrl": "https://app.agenticos.tech/chat/<token>",
  "suggestions": ["What can you do?", "Pricing", "Talk to our team"],
  "followup": true
}
keyใช้ทำอะไร
name avatarUrlชื่อและรูปของบอท (avatarUrl อาจเป็น null)
suggestionsปุ่มคำถามแนะนำ สูงสุด 6 ปุ่ม กดแล้วส่งข้อความนั้นตามปกติ
prechatฟอร์มก่อนแชท ถ้าเปิดไว้ (ดู 3.5) ค่า null คือไม่ต้องทำ
styleสีและข้อความของวิดเจ็ตเว็บ แอปจะใช้หรือไม่ใช้ก็ได้

token ผิดจะได้ 200 พร้อม {"name": null, "avatarUrl": null, "style": null} ไม่ใช่ 401

POST/webhooks/web

ส่งข้อความ (JSON)

request
{
  "token": "<embed token>",
  "session_id": "wsx-1ecb99647586f8100cb4b7ca25d51ac9",
  "text": "Machine 3 at the Lat Phrao branch is not working",
  "user_id": "member-42",
  "user_hash": "<64 hex>",
  "user_name": "Somying Jaidee",
  "user_phone": "0812345678",
  "user_email": "[email protected]"
}
fieldบังคับจำกัด
token✓8–200
session_id✓6–128 (แนะนำ 8–128) ห้ามขึ้นต้น usr:
text✓1–4000 ตัวอักษร
visitor_name≤120 ชื่อแสดงของผู้ใช้ไม่ระบุตัวตน
user_id user_hash≤100, ≤64 ดูข้อ 2.2
user_name user_email user_phone≤120, ≤200, ≤40 ใช้เมื่อ user_hash ถูกต้องเท่านั้น
สถานะbodyความหมาย
200{"ok": true, "conversation_id": "<uuid>"}รับแล้ว บอทจะตอบใน 1–10 วินาที ให้ poll
401{"ok": false}token ผิด หรือช่องทางถูกลบ
429{"ok": false, "error": "rate_limited"}ส่งถี่เกิน (ดูข้อ 5)
400{"ok": false, "error": "reserved_session_id"}session_id ขึ้นต้น usr:
409{"ok": false, "error": "prechat_required"}ต้องส่งฟอร์มก่อนแชทก่อน (3.5)
422{"detail": [...]}field ผิดรูปแบบหรือยาวเกิน (error มาตรฐานของ FastAPI)

GET/webhooks/web/poll

?token=…&session_id=…&after=<cursor>&user_id=…&user_hash=…

200
{
  "messages": [
    {
      "direction": "out",
      "sender_type": "ai",
      "text": "Hi! How can I help?",
      "created_at": "2026-09-30T08:15:02.418221+00:00",
      "attachment_url": null,
      "attachment_kind": null,
      "attachment_name": null,
      "card": null,
      "checkout_url": null
    }
  ],
  "cursor": "2026-09-30T08:15:02.418221+00:00",
  "prechat_done": true
}
key ของข้อความค่า
direction"in" ผู้ใช้ส่ง · "out" ฝั่งธุรกิจตอบ
sender_type"customer" · "ai" บอท · "human" ทีมงาน
textข้อความ อาจมี Markdown แบบง่าย เช่น **ตัวหนา** และรายการ 1.
created_atISO-8601 มี timezone
attachment_url attachment_kind attachment_nameไฟล์แนบ kind เป็น "image" หรือ "file"
cardการ์ดสินค้า {"kind": "products", "items": [...]}
checkout_urlลิงก์ชำระเงินในข้อความ (ถ้ามี) ทำเป็นปุ่มได้

กติกา cursor

  • ครั้งแรกไม่ต้องส่ง after จะได้ประวัติทั้งหมด ครั้งละสูงสุด 50 ข้อความ เรียงเก่าไปใหม่
  • ครั้งต่อไปส่ง after เป็น cursor ล่าสุด และต้อง URL-encode เพราะมีเครื่องหมาย +
  • ไม่มีข้อความใหม่: messages ว่าง และ cursor เป็นค่าเดิมที่ส่งไป
  • ได้ครบ 50 ข้อความ: poll ซ้ำทันทีด้วย cursor ใหม่ จนได้น้อยกว่า 50
  • ข้อความไม่มี id ถ้าต้องกันซ้ำ ใช้ key created_at|direction|text|attachment_url แบบเดียวกับวิดเจ็ตเว็บ
  • ข้อความที่ผู้ใช้เพิ่งส่งจะกลับมาใน poll ด้วย (direction "in") ถ้าแสดงไปแล้วแบบ optimistic ให้ตัดซ้ำด้วย key ข้างบน

ความถี่ที่แนะนำ

  • ขณะหน้าแชทเปิด: ทุก 4 วินาที
  • 20 วินาทีหลังผู้ใช้ส่งข้อความ: ทุก 1 วินาที และ poll ครั้งแรกหลังส่งประมาณ 600 ms
  • แอปอยู่เบื้องหลัง: หยุด poll แล้วใช้ push (ข้อ 4)
  • API ไม่มีสถานะ "กำลังพิมพ์" แสดงเองหลังส่งข้อความได้ สูงสุด 30 วินาทีหรือจนได้คำตอบ

POST/webhooks/web/upload

ส่งรูปหรือไฟล์ เป็น multipart/form-data

fieldบังคับหมายเหตุ
token session_id✓
file✓ไม่เกิน 20 MB ห้ามว่าง รับทุกชนิดไฟล์ (content-type ขึ้นต้น image/ แสดงเป็นรูป)
textคำอธิบายรูป
user_id user_hashต้องส่งถ้าผูกสมาชิก

สำเร็จ: 200 {"ok": true, "conversation_id": "<uuid>", "attachment_url": "<url>"} · error: 401, 429, 400 reserved_session_id, 400 empty file, 413 file too large, 409 prechat_required

POST/webhooks/web/prechat

ฟอร์มก่อนแชท ใช้เมื่อ config.prechat ไม่เป็น null ผู้ใช้ที่ผูกสมาชิกแล้วไม่ต้องทำ

request
{ "token": "…", "session_id": "…", "name": "Somying", "phone": "0812345678", "email": "", "company": "", "consent": true }
  • config.prechat.fields บอกว่าแต่ละช่องเป็น off, optional หรือ required
  • ถ้า config.prechat.consent_text ไม่ว่าง ต้องส่ง consent: true
  • เบอร์โทรต้องมีตัวเลขอย่างน้อย 9 หลัก อีเมลต้องมี @ และโดเมน
  • ผลลัพธ์: 200 {"ok": true, "conversation_id"} · 200 {"ok": true, "skipped": true} ถ้าไม่ได้เปิดฟอร์ม · 422 {"ok": false, "error": "invalid", "field", "reason"}

3.6 ขอคุยกับเจ้าหน้าที่

ไม่มี endpoint แยก ให้ส่งข้อความปกติ เช่น "ขอคุยกับเจ้าหน้าที่" หรือทำปุ่ม "ช่วยเหลือ" ที่ส่งข้อความนี้ บอทจะส่งต่อทีมงาน และทีมตอบในบทสนทนาเดียวกัน (sender_type "human")

/webhooks/web/handoff และ /handoff/claim ไม่ใช่การส่งต่อเจ้าหน้าที่ แต่เป็นรหัสใช้ครั้งเดียวสำหรับย้ายบทสนทนาจากวิดเจ็ตไปหน้าแชทเต็มจอ แอปไม่ต้องใช้

4. แจ้งเตือนเมื่อมีคำตอบ (Webhook → Push)

ถ้าผู้ใช้ปิดแอปอยู่ แอปจะไม่ได้ poll ตั้ง webhook ไว้ AgenticOS จะแจ้งเซิร์ฟเวอร์ของแอปทุกครั้งที่บอทหรือทีมงานตอบ แล้วเซิร์ฟเวอร์ส่ง FCM/APNs ต่อ AgenticOS ไม่เก็บ device token

4.1 ตั้งค่า

  1. OmniChat → ช่องทาง → ช่องทางเว็บ → "แจ้งเตือนในแอปมือถือเมื่อมีคำตอบ" ใส่ URL ของเซิร์ฟเวอร์ (https และเป็นโฮสต์สาธารณะ) แล้วบันทึก ระบบจะสร้าง signing key ให้
  2. คัดลอก signing key ไปเก็บบนเซิร์ฟเวอร์
  3. กด "ส่งเหตุการณ์ทดสอบ" ต้องเห็นข้อความว่าเซิร์ฟเวอร์ตอบกลับ 200

4.2 สิ่งที่เซิร์ฟเวอร์ได้รับ

headerค่า
X-AgenticOS-Eventmessage.created (หรือ test จากปุ่มทดสอบ)
X-AgenticOS-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
User-AgentAgenticOS-Webhook/1.0
body
{
  "event": "message.created",
  "channel_id": "75538105-e14a-4f76-bb2d-dd1cc048b0bc",
  "conversation_id": "183b01f7-fd8e-4951-a07f-d323fbf6cfac",
  "user_id": "member-42",
  "session_id": null,
  "message": {
    "id": "77072727-82a3-4497-ad6d-7582175cf8a4",
    "sender": "bot",
    "sender_name": "Assistant",
    "preview": "The payback period depends on several things…",
    "has_attachment": false,
    "created_at": "2026-09-30T08:15:02.418221+00:00"
  },
  "sent_at": "2026-09-30T08:15:04.102118+00:00",
  "visitor_active": false
}
keyความหมาย
user_id / session_idผู้รับ มีค่าอย่างใดอย่างหนึ่ง ใช้หา device token ของผู้ใช้
message.sender"bot" หรือ "agent" (ทีมงาน)
message.previewข้อความตัดไม่เกิน 200 ตัวอักษร ใช้เป็นเนื้อหา notification
message.idใช้กันแจ้งเตือนซ้ำ (collapse key)
visitor_activetrue คือผู้ใช้ poll ภายในประมาณ 100 วินาที น่าจะเปิดแอปอยู่ ไม่ต้องส่ง push ก็ได้
  • เป็นสัญญาณ ไม่ใช่ข้อมูลเต็ม เนื้อหาเต็มให้แอปดึงผ่าน poll
  • คำตอบหลายข้อความภายในประมาณ 1.5 วินาทีจะรวมเป็น event เดียว
  • ตอบ 2xx ภายใน 5 วินาที ถ้า timeout หรือ 5xx ระบบลองใหม่อีก 2 ครั้ง (หลัง 2 และ 8 วินาที) ส่วน 4xx ไม่ลองซ้ำ
  • ไม่รับประกันลำดับ อาจได้ซ้ำ และไม่ตาม redirect

4.3 ตรวจลายเซ็น (ต้องทำ)

  1. แยก t และ v1 จาก header
  2. คำนวณ HMAC-SHA256(signing key, "<t>." + raw body) เป็น hex โดยใช้ raw body ก่อน parse JSON
  3. เทียบกับ v1 แบบ constant-time ถ้าไม่ตรง ตอบ 401
  4. ถ้า t เก่ากว่า 5 นาที ให้ปฏิเสธ (กัน replay)
Node.js (Express)
app.post("/omnichat/reply", express.raw({ type: "application/json" }), (req, res) => {
  const sig = Object.fromEntries(String(req.get("X-AgenticOS-Signature") || "")
    .split(",").map((p) => p.split("=", 2)));
  const expected = crypto.createHmac("sha256", process.env.OMNICHAT_PUSH_KEY)
    .update(`${sig.t}.`).update(req.body).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  if (!fresh || !sig.v1 || sig.v1.length !== expected.length
      || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1)))
    return res.sendStatus(401);
  const evt = JSON.parse(req.body);
  res.sendStatus(200);                       // answer first, then push
  if (evt.event === "message.created" && !evt.visitor_active) {
    sendPush(evt.user_id ?? evt.session_id, {
      title: evt.message.sender_name || "New message",
      body: evt.message.preview || "📎 Attachment",
      collapseKey: evt.message.id,
      data: { conversation_id: evt.conversation_id },
    });
  }
});
Python (FastAPI)
@app.post("/omnichat/reply")
async def omnichat_reply(request: Request):
    raw = await request.body()
    parts = dict(p.split("=", 1) for p in request.headers.get("X-AgenticOS-Signature", "").split(","))
    expected = hmac.new(PUSH_KEY.encode(), f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(401)
    evt = json.loads(raw)
    # … push to evt["user_id"] or evt["session_id"]
    return {"ok": True}

แอปต้องลงทะเบียน device token กับเซิร์ฟเวอร์ของแอปเอง โดยผูกกับ user_id (หรือ session_id ถ้าไม่ระบุตัวตน)

5. Rate limit

กลุ่มendpointจำกัด
ข้อความPOST /webhooks/web /prechatตามที่ตั้งในองค์กร ค่าเริ่มต้น 20 ครั้ง/นาที ต่อ token ต่อ IP
อัปโหลด/uploadค่าเดียวกัน แยกนับจากข้อความ
poll/poll180 ครั้ง/นาที ต่อ token ต่อ IP
config/configไม่จำกัด

ได้ 429 ให้รอ 2–5 วินาทีแล้วลองใหม่

6. ลำดับการทำงานที่แนะนำในแอป

  1. เปิดหน้าแชท: GET /config
  2. ถ้าล็อกอินอยู่: ขอ user_hash จาก backend ของแอป
  3. GET /poll โดยไม่ส่ง after เพื่อแสดงประวัติ แล้วเก็บ cursor
  4. ถ้า prechat ไม่เป็น null และ prechat_done เป็น false: แสดงฟอร์ม แล้ว POST /prechat
  5. ผู้ใช้พิมพ์: แสดงข้อความทันที แล้ว POST /webhooks/web จากนั้น poll ทุก 1 วินาทีเป็นเวลา 20 วินาที
  6. ออกจากหน้าแชท หรือแอปเข้าเบื้องหลัง: หยุด poll
  7. ได้ push: เปิดหน้าแชท แล้ว GET /poll?after=<cursor ที่เก็บไว้>

7. ตรวจสอบปัญหา

อาการสาเหตุที่พบบ่อย
ส่งได้แต่ไม่เห็นคำตอบpoll ไม่ได้ส่ง user_id/user_hash ชุดเดียวกับตอนส่ง หรือไม่ได้ URL-encode cursor
ผู้ใช้กลายเป็นไม่ระบุตัวตนuser_hash คำนวณผิด หรือยังไม่ได้สร้าง identity key
ได้ 409 prechat_requiredต้องส่งฟอร์มก่อนแชท หรือผูกสมาชิกเพื่อข้ามฟอร์ม
webhook ไม่มาURL ไม่ใช่ https, resolve เป็น IP ภายใน, หรือเซิร์ฟเวอร์ตอบไม่ใช่ 2xx ลองกด "ส่งเหตุการณ์ทดสอบ"
ลายเซ็นไม่ตรงใช้ JSON ที่ parse แล้ว stringify ใหม่แทน raw body หรือใช้ key ผิดตัว (ต้องเป็น signing key)