รับผลผ่าน webhook
webhook คือให้ Gowajee ส่งผลของแต่ละ call มาที่ server ของคุณทันทีที่ผลพร้อม ส่งเป็น POST 1 ครั้ง พร้อม JSON
body แบบ CALL_RESULT วิธีนี้ได้ผลเร็วกว่า polling แต่ถ้า server ล่มนานเกินช่วงที่ระบบ retry
ผลนั้นจะหายไป เราจึงแนะนำให้ใช้ polling เป็นหลัก และใช้ webhook ควบคู่กันไป
ตั้งค่า Webhook
หัวข้อที่มีชื่อว่า “ตั้งค่า Webhook”-
สร้าง endpoint บน server ของคุณ โดย endpoint ต้อง
- เข้าถึงได้จากอินเทอร์เน็ต และควรใช้ HTTPS Gowajee ไม่ได้บังคับ แต่ถ้าใช้ URL แบบ
http://ข้อมูลลูกค้าจะส่งไป แบบไม่เข้ารหัส - รับ
POSTแบบContent-Type: application/json - ตอบ status
2xxอะไรก็ได้กลับไปภายใน 10 วินาที แล้วค่อยทำงานจริงทีหลัง (เช่น โยนเข้า queue) - ตอบที่ URL นั้นเองโดยตรง ระบบไม่ตาม redirect และนับ
3xxเป็นการส่งไม่สำเร็จ
- เข้าถึงได้จากอินเทอร์เน็ต และควรใช้ HTTPS Gowajee ไม่ได้บังคับ แต่ถ้าใช้ URL แบบ
-
ใส่ secret ไว้ใน path เช่น
https://your-server.example.com/gowajee/webhook/9f2c7e1b…เพราะ request ไม่มี signature secret นี้จึงเป็นตัวบอกว่า request มาจาก Gowajee จริง ดูป้องกัน endpoint -
บันทึก URL ใน dashboard เข้าสู่ระบบด้วยบัญชี Owner หรือ Admin ไปที่ ระบบ → นักพัฒนา วาง URL ในช่อง Webhook URL แล้วกดบันทึก
- ตั้งได้ 1 URL ต่อองค์กร และ URL นี้จะได้รับผลจากทุก campaign
- เปลี่ยน URL แล้ว ผลรายการถัดไปที่ระบบส่งจะไปที่ URL ใหม่ทันที
-
ทดสอบ เริ่ม campaign เล็กๆ ที่โทรหาเบอร์ของคุณเอง ระหว่างที่โค้ดยังไม่เสร็จ เอา URL ของเครื่องมือดู request (เช่น webhook.site) มาใส่เป็น webhook URL ก่อนได้ จะได้เห็น payload จริง ถ้า server รันอยู่ในเครื่อง ให้เปิดออกสู่ ภายนอกด้วย tunnel อย่าง ngrok หรือ cloudflared
-
เพิ่ม polling กันพลาด รันโค้ด sync แบบ polling ทุกๆ ไม่กี่นาทีไว้ด้วย ผลที่ไม่ได้ มาทาง webhook จะได้ยังเก็บได้ครบ
ส่งมาตอนไหน
หัวข้อที่มีชื่อว่า “ส่งมาตอนไหน”| เหตุการณ์ | call.callStatus |
callResult |
|---|---|---|
| call ติดต่อลูกค้าได้ และวิเคราะห์บทสนทนาเสร็จแล้ว | COMPLETED |
ผลการวิเคราะห์ หรือผลสำรอง |
| call รอบนั้นจบไปโดยไม่ได้คุยกัน | NO_ANSWER, BUSY, FAILED หรือ CANCELLED เพราะ call รอใน queue นานเกินไป |
null |
| Gowajee วิเคราะห์ซ้ำ call ที่คุณเคยได้ผลไปแล้ว (ดูด้านล่าง) | COMPLETED |
ผลใหม่ พร้อม isReAnalysis: true |
- โทร 1 รอบ ได้ webhook 1 ครั้ง recipient ที่โทรหา 3 รอบจึงอาจได้ webhook 3 ครั้ง ให้จัดกลุ่มตาม
recipient.tel - call ที่เป็น
ANALYSIS_FAILEDอาจได้ผลตามมาทีหลัง ตอนนั้นคุณจะได้ webhookCOMPLETEDตามปกติ
ระบบไม่ส่ง webhook ในกรณีต่อไปนี้ ดู call เหล่านี้ได้ที่ /calls เท่านั้น
- call ที่วิเคราะห์ไม่สำเร็จ (
ANALYSIS_FAILED) - call ที่เป็น
EXPIRED - call ที่รอโทรอยู่แล้วกลายเป็น
CANCELLEDเพราะคุณยกเลิก campaign - call ที่
COMPLETEDแต่ Gowajee ไม่ได้วิเคราะห์ เพราะไม่มีทั้งไฟล์บันทึกเสียงและ transcript หรือ agent หยุดเพราะ error (agentStatusไม่ใช่COMPLETED)
Payload
หัวข้อที่มีชื่อว่า “Payload”{ "type": "CALL_RESULT", "payload": { "agentName": "Payment reminder", "campaignName": "October payment reminder", "isReAnalysis": false, "call": { "id": "fc1c320d-d133-4d48-8118-5932d93149fd", "sessionId": "0f72ce05-5481-4bfe-bde4-6a675689822c", "callStatus": "COMPLETED", "callStartedAt": "2026-10-01T02:11:58.472Z", "callEndedAt": "2026-10-01T02:13:58.592Z", "callDuration": "120.120", "errorMessage": null }, "recipient": { "tel": "0812345678", "customerName": "Somchai", "callAttempt": 1, "latestCallStatus": "COMPLETED", "agentParams": { "customer_name": "Somchai", "amount_due": 1500 } }, "agent": { "id": "33ea7a25-03b2-4614-a004-fa3bf9984bd1", "name": "Payment reminder", "paramsSchema": { "customer_name": { "type": "text", "required": true }, "amount_due": { "type": "number", "required": true } } }, "campaign": { "id": "eac2489c-672c-455e-ab8c-4e0ecd18d7dc", "name": "October payment reminder", "status": "RUNNING", "maxAttempts": 3, "maxConcurrentCalls": 15, "retryInterval": 14400000, "scheduleStartAt": "2026-10-01T02:00:00.000Z", "scheduleEndAt": null, "scheduleDateTimeWindow": [ { "dayOfWeek": 1, "time": [{ "start": "09:00", "end": "18:00" }] } ], "sipAccountId": null }, "callResult": { "version": "v2", "fixedAttr": { "callSummary": "ลูกค้ารับทราบยอดค้างชำระ 1,500 บาท และนัดชำระวันที่ 5 ตุลาคม", "funnel": { "funnelFlow": ["greeting", "verify_identity", "inform_amount", "negotiate", "closing"], "value": "closing" } }, "postCallAnalyticsResult": { "promise_to_pay": { "type": "boolean", "required": true, "value": true }, "payment_date": { "type": "text", "required": false, "description": "Date the customer promised to pay", "value": "2026-10-05" } } }, "recordUrl": "https://storage.example.com/recordings/fc1c320d.wav?X-Amz-Expires=3600&X-Amz-Signature=...", "transcriptFileUrl": "https://storage.example.com/transcripts/fc1c320d.json?X-Amz-Expires=3600&X-Amz-Signature=...", "dataExpired": { "audio": false, "personalData": false, "output": false } }}| Field | Type | คำอธิบาย |
|---|---|---|
type |
string | เป็น "CALL_RESULT" เสมอ |
payload.isReAnalysis |
boolean | มีมาทุกครั้ง เป็น true เมื่อเป็นผลที่ส่งซ้ำของ call ที่คุณเคยได้รับไปแล้ว ดูการวิเคราะห์ซ้ำ |
payload.agentName / campaignName |
string | ชื่อ ไว้แสดงผล |
payload.call.id |
string (UUID) | ID ของ call ให้ใช้เป็น idempotency key เป็น id ตัวเดียวกับใน /calls และ GET /api/call/:id |
payload.call.sessionId |
string | session ID ของ call รอบนี้ ใช้อ้างอิงตอนติดต่อทีม support ของ Gowajee |
payload.call.callStatus |
string | ดู Status ของ Call |
payload.call.callStartedAt |
string (UTC) หรือ null |
เวลาที่เริ่มคุย เป็น null ถ้าลูกค้าไม่ได้รับสายเลย |
payload.call.callEndedAt |
string (UTC) หรือ null |
เวลาที่ call รอบนี้จบ call ที่ไม่มีคนรับสายก็มีค่านี้ด้วย |
payload.call.callDuration |
string ที่เป็นตัวเลข หรือ null |
หน่วยเป็นวินาที ทศนิยม 3 ตำแหน่ง ส่งมาเป็น string เช่น "120.120" ให้แปลงด้วย parseFloat/float() ก่อนใช้ |
payload.call.errorMessage |
string หรือ null |
สาเหตุที่ call ล้มเหลว (ถ้าระบบรู้) |
payload.recipient |
object | ข้อมูล recipient ณ ตอนที่ส่ง webhook: tel, customerName, callAttempt, latestCallStatus, agentParams ค่า callAttempt คือจำนวนรอบที่โทรจบไปแล้วจนถึงตอนนั้น ไม่ใช่เลขรอบของ call นี้ จึงไม่ควรใช้เรียงลำดับ webhook |
payload.agent |
object | id, name, paramsSchema |
payload.campaign |
object | การตั้งค่าของ campaign ตอนที่ส่ง webhook retryInterval มีหน่วยเป็นมิลลิวินาที |
payload.callResult |
object หรือ null |
ผลการวิเคราะห์ เป็น null เมื่อไม่มีผล เช่น NO_ANSWER ดู callResult ด้านล่าง |
payload.recordUrl |
string หรือ null |
ลิงก์ดาวน์โหลดไฟล์บันทึกเสียง ลิงก์หมดอายุภายใน 1 ชั่วโมงหรือเร็วกว่านั้น ให้เก็บไฟล์ไว้เอง หรือขอลิงก์ใหม่ทีหลังด้วย /record-url |
payload.transcriptFileUrl |
string หรือ null |
ลิงก์ไฟล์ transcript แบบ JSON ลิงก์นี้ก็หมดอายุภายใน 1 ชั่วโมงหรือเร็วกว่านั้นเหมือนกัน |
payload.dataExpired |
object | boolean 3 ตัว คือ audio, personalData, output ดูการลบข้อมูลตามระยะเวลาเก็บรักษา |
callResult
หัวข้อที่มีชื่อว่า “callResult”| Field | คำอธิบาย |
|---|---|
version |
agent ปัจจุบันส่ง "v2" ส่วน agent รุ่นเก่าส่ง "v1" |
fixedAttr.callSummary |
สรุปบทสนทนาสั้นๆ ส่วนใหญ่เป็นภาษาไทย |
fixedAttr.funnel |
มีเฉพาะเมื่อ agent กำหนด funnel ไว้ funnelFlow คือขั้นตอนทั้งหมดเรียงตามลำดับ ส่วน value คือขั้นตอนที่ไปถึงไกลที่สุด |
postCallAnalyticsResult (v2) |
field วิเคราะห์ที่คุณกำหนดเอง แต่ละ key เป็น object { type, value, required, description?, enumVariant? } ให้อ่านค่าที่ .value |
customizableAttr (v1) |
ข้อมูลเดียวกัน แต่เป็นชื่อเก่า |
const fields = callResult?.postCallAnalyticsResult ?? callResult?.customizableAttr ?? {};const promiseToPay = fields.promise_to_pay?.value; // trueเมื่อวิเคราะห์ไม่สำเร็จ
หัวข้อที่มีชื่อว่า “เมื่อวิเคราะห์ไม่สำเร็จ”บางครั้ง Gowajee ก็วิเคราะห์บทสนทนาไม่ได้ กรณีนี้ call ยังเป็น COMPLETED แต่ callResult จะมีรูปแบบตายตัวแบบนี้
แทน field ของคุณ
{ "version": "v2", "fixedAttr": { "callSummary": "Failed to process conversation", "funnel": { "funnelFlow": ["Unknown"], "value": "Unknown" } }, "postCallAnalyticsResult": { "error": { "type": "string", "required": true, "enumVariant": null, "description": "Configuration loading failed for …", "value": "Configuration loading failed" } }}- ถ้าใน call ไม่มีใครพูดอะไรเลย
callSummaryและerror.valueจะเป็น"No conversation" funnelFlowอาจมีขั้นตอน funnel จริงของ agent แต่valueจะเป็น"Unknown"- ให้เช็กว่ามี
postCallAnalyticsResult.errorหรือไม่ ถ้ามี แปลว่าผลนี้ไม่มี field ของคุณ ให้ขอ Gowajee วิเคราะห์ call นั้นซ้ำ - การวิเคราะห์ซ้ำจะไม่เอาผลแบบนี้ไปเขียนทับผลที่ดีอยู่แล้ว
การวิเคราะห์ซ้ำ: isReAnalysis
หัวข้อที่มีชื่อว่า “การวิเคราะห์ซ้ำ: isReAnalysis”Gowajee วิเคราะห์ call ที่จบไปแล้วใหม่ได้ เช่น หลังปรับคำถามวิเคราะห์ของ agent ตามที่คุณขอ ทุก call ที่วิเคราะห์ซ้ำ
คุณจะได้ CALL_RESULT อีกครั้ง โดยมี
call.idและcall.sessionIdตัวเดิมcallResultชุดใหม่isReAnalysis: true
วิธีรับมือ เก็บผลแบบ upsert โดยใช้ call.id เป็น key ผลใหม่จะเขียนทับผลเก่าเอง ถ้าระบบของคุณต้องไม่
ทำ action ทางธุรกิจซ้ำ (เช่น ส่ง SMS) ให้เช็ก isReAnalysis ก่อนทำ action
if (payload.isReAnalysis) { await results.upsert(payload.call.id, payload.callResult); // update data only} else { await results.upsert(payload.call.id, payload.callResult); await triggerFollowUp(payload); // first result: act on it}การส่ง Webhook และการส่งซ้ำ
หัวข้อที่มีชื่อว่า “การส่ง Webhook และการส่งซ้ำ”| Method | POST, Content-Type: application/json |
| Header | User-Agent: Gowajee-Webhook/1.0 และ X-Gowajee-Event: CALL_RESULT |
| สำเร็จ | status 2xx อะไรก็ได้ |
| Timeout | 10 วินาที ต่อครั้ง ถ้าไม่ตอบภายในเวลานี้ นับเป็นการส่งไม่สำเร็จ |
| Redirect | ไม่ตาม 3xx นับเป็นการส่งไม่สำเร็จ ให้บันทึก URL ปลายทางไว้ใน dashboard |
| ส่งซ้ำ | รวมทั้งหมด 3 ครั้ง Gowajee รอ 1 วินาทีก่อนส่งครั้งที่ 2 และรอ 2 วินาทีก่อนส่งครั้งที่ 3 หลังจากนั้นระบบจะไม่ส่งผลนี้มาอีก ทั้งหมดใช้เวลาไม่กี่วินาที หรือราว 35 วินาทีถ้า timeout ทุกครั้ง |
| ลำดับ | ไม่รับประกัน NO_ANSWER ของรอบที่ 2 อาจมาถึงหลังข้อมูลของรอบที่ 1 |
| ข้อมูลซ้ำ | เกิดขึ้นได้ ให้ upsert ด้วย call.id เสมอ |
ป้องกัน endpoint
หัวข้อที่มีชื่อว่า “ป้องกัน endpoint”Webhook request ไม่มี signature ถ้าจะให้แน่ใจว่า request มาจาก Gowajee จริง ให้ทำดังนี้
- ใช้ HTTPS Gowajee รับ URL แบบ
http://ได้ แต่ข้อมูลจะส่งไปแบบไม่เข้ารหัส - ใส่ secret ที่สุ่มมายาวๆ ไว้ใน URL เช่น
https://your-server.example.com/gowajee/webhook/9f2c7e…แล้ว ปฏิเสธ request ที่ path ไม่ตรง - ถือว่า payload เป็นแค่การแจ้งเตือน ถ้าเป็นเรื่องสำคัญ ให้ยืนยันอีกทีด้วยการเรียก
GET /api/call/:idด้วย API key ของคุณ
header User-Agent และ X-Gowajee-Event ช่วยให้ route request ได้ง่ายขึ้น แต่ใครก็ส่ง header เหล่านี้มาได้
จึงใช้พิสูจน์ไม่ได้ว่า request มาจาก Gowajee จริง
การลบข้อมูลตามระยะเวลาเก็บรักษา (Data retention)
หัวข้อที่มีชื่อว่า “การลบข้อมูลตามระยะเวลาเก็บรักษา (Data retention)”ถ้าองค์กรของคุณตั้งนโยบายเก็บข้อมูลไว้ ระบบจะลบข้อมูลเก่าเมื่อครบเวลาที่กำหนด และ webhook จะบอกไว้ใน flag เหล่านี้
| Flag | เมื่อเป็น true |
|---|---|
dataExpired.audio |
recordUrl เป็น null เพราะระบบลบไฟล์บันทึกเสียงไปแล้ว |
dataExpired.personalData |
ระบบ mask recipient.tel ไว้ customerName เป็น "" และ transcriptFileUrl เป็น null ส่วน agentParams ยังมี key ครบ แต่ทุกค่าจะเป็น null |
dataExpired.output |
callResult ยังมี key ครบ แต่ทุกค่าจะเป็น null รวมถึง version ด้วย |
ทั้งสองกรณี key ที่ขึ้นต้นด้วย metadata__ จะยังมีค่าเดิมอยู่
ส่วนใหญ่จะเจอกรณีนี้ในผลวิเคราะห์ซ้ำของ call เก่าๆ
ตัวอย่างโค้ดฝั่งรับ
หัวข้อที่มีชื่อว่า “ตัวอย่างโค้ดฝั่งรับ”โค้ดฝั่งรับแบบครบชุด เช็ก secret ก่อน ตอบกลับทันที แล้วค่อยจัดการผลเบื้องหลัง
- upsert ด้วย
call.idผลที่ส่งซ้ำและผลวิเคราะห์ซ้ำจึงไม่ทำให้ข้อมูลเพี้ยน - เรียก follow-up action เฉพาะผลแรก (
isReAnalysis: false) เท่านั้น - ดาวน์โหลดไฟล์บันทึกเสียงภายในชั่วโมงนั้น ก่อนลิงก์หมดอายุ
import express from 'express';
const app = express();app.use(express.json({ limit: '5mb' }));
app.post('/gowajee/webhook/:secret', (req, res) => { if (req.params.secret !== process.env.GOWAJEE_WEBHOOK_SECRET) return res.sendStatus(404); res.sendStatus(200); // 1. acknowledge first — never make Gowajee wait
const { type, payload } = req.body ?? {}; if (type !== 'CALL_RESULT') return; handleResult(payload).catch((err) => console.error('gowajee webhook', err)); // 2. work in the background});
async function handleResult(p) { const fields = p.callResult?.postCallAnalyticsResult ?? p.callResult?.customizableAttr ?? {};
// 3. Upsert keyed on the call ID: a repeat or a re-analysis just overwrites the row. await db.query( `INSERT INTO gowajee_call (call_id, session_id, campaign_id, tel, status, attempt, duration_s, summary, fields, received_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (call_id) DO UPDATE SET status = EXCLUDED.status, attempt = EXCLUDED.attempt, duration_s = EXCLUDED.duration_s, summary = EXCLUDED.summary, fields = EXCLUDED.fields, received_at = now()`, [ p.call.id, p.call.sessionId, p.campaign.id, p.recipient.tel, p.call.callStatus, p.recipient.callAttempt, p.call.callDuration == null ? null : parseFloat(p.call.callDuration), // a string like "120.120" p.callResult?.fixedAttr?.callSummary ?? null, JSON.stringify(Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, v?.value ?? null]))), ], );
// 4. Business actions only once, on the first result. if (!p.isReAnalysis && p.call.callStatus === 'COMPLETED') { await triggerFollowUp(p); // e.g. send an SMS, update your CRM }
// 5. The recording link expires within 1 hour: save the file now if you keep recordings. if (p.recordUrl) { const audio = Buffer.from(await (await fetch(p.recordUrl)).arrayBuffer()); await storage.put(`recordings/${p.call.id}.wav`, audio); }}
app.listen(3000);import jsonimport os
import httpxfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Request
app = FastAPI()SECRET = os.environ["GOWAJEE_WEBHOOK_SECRET"]
@app.post("/gowajee/webhook/{secret}")async def gowajee_webhook(secret: str, request: Request, tasks: BackgroundTasks): if secret != SECRET: raise HTTPException(status_code=404) body = await request.json() if body.get("type") == "CALL_RESULT": tasks.add_task(handle_result, body["payload"]) # runs after the response is sent return {"ok": True} # 1. acknowledge straight away
async def handle_result(p: dict) -> None: result = p.get("callResult") or {} fields = result.get("postCallAnalyticsResult") or result.get("customizableAttr") or {} duration = p["call"].get("callDuration")
# 2. Upsert keyed on the call ID: a repeat or a re-analysis just overwrites the row. await db.execute( """ INSERT INTO gowajee_call (call_id, session_id, campaign_id, tel, status, attempt, duration_s, summary, fields, received_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (call_id) DO UPDATE SET status = EXCLUDED.status, attempt = EXCLUDED.attempt, duration_s = EXCLUDED.duration_s, summary = EXCLUDED.summary, fields = EXCLUDED.fields, received_at = now() """, p["call"]["id"], p["call"].get("sessionId"), p["campaign"]["id"], p["recipient"]["tel"], p["call"]["callStatus"], p["recipient"]["callAttempt"], float(duration) if duration is not None else None, # a string like "120.120" (result.get("fixedAttr") or {}).get("callSummary"), json.dumps({k: (v or {}).get("value") for k, v in fields.items()}), )
# 3. Business actions only once, on the first result. if not p["isReAnalysis"] and p["call"]["callStatus"] == "COMPLETED": await trigger_follow_up(p)
# 4. The recording link expires within 1 hour: save the file now if you keep recordings. if p.get("recordUrl"): async with httpx.AsyncClient(timeout=120) as client: audio = (await client.get(p["recordUrl"])).content await storage_put(f"recordings/{p['call']['id']}.wav", audio)CREATE TABLE gowajee_call ( call_id uuid PRIMARY KEY, -- payload.call.id session_id text, -- payload.call.sessionId campaign_id uuid NOT NULL, tel text, status text NOT NULL, attempt int, -- payload.recipient.callAttempt (a running count) duration_s numeric, summary text, fields jsonb, -- { "promise_to_pay": true, ... } received_at timestamptz NOT NULL);