ข้ามไปยังเนื้อหา

รับผลผ่าน webhook

webhook คือให้ Gowajee ส่งผลของแต่ละ call มาที่ server ของคุณทันทีที่ผลพร้อม ส่งเป็น POST 1 ครั้ง พร้อม JSON body แบบ CALL_RESULT วิธีนี้ได้ผลเร็วกว่า polling แต่ถ้า server ล่มนานเกินช่วงที่ระบบ retry ผลนั้นจะหายไป เราจึงแนะนำให้ใช้ polling เป็นหลัก และใช้ webhook ควบคู่กันไป

  1. สร้าง endpoint บน server ของคุณ โดย endpoint ต้อง

    • เข้าถึงได้จากอินเทอร์เน็ต และควรใช้ HTTPS Gowajee ไม่ได้บังคับ แต่ถ้าใช้ URL แบบ http:// ข้อมูลลูกค้าจะส่งไป แบบไม่เข้ารหัส
    • รับ POST แบบ Content-Type: application/json
    • ตอบ status 2xx อะไรก็ได้กลับไปภายใน 10 วินาที แล้วค่อยทำงานจริงทีหลัง (เช่น โยนเข้า queue)
    • ตอบที่ URL นั้นเองโดยตรง ระบบไม่ตาม redirect และนับ 3xx เป็นการส่งไม่สำเร็จ
  2. ใส่ secret ไว้ใน path เช่น https://your-server.example.com/gowajee/webhook/9f2c7e1b… เพราะ request ไม่มี signature secret นี้จึงเป็นตัวบอกว่า request มาจาก Gowajee จริง ดูป้องกัน endpoint

  3. บันทึก URL ใน dashboard เข้าสู่ระบบด้วยบัญชี Owner หรือ Admin ไปที่ ระบบ → นักพัฒนา วาง URL ในช่อง Webhook URL แล้วกดบันทึก

    • ตั้งได้ 1 URL ต่อองค์กร และ URL นี้จะได้รับผลจากทุก campaign
    • เปลี่ยน URL แล้ว ผลรายการถัดไปที่ระบบส่งจะไปที่ URL ใหม่ทันที
  4. ทดสอบ เริ่ม campaign เล็กๆ ที่โทรหาเบอร์ของคุณเอง ระหว่างที่โค้ดยังไม่เสร็จ เอา URL ของเครื่องมือดู request (เช่น webhook.site) มาใส่เป็น webhook URL ก่อนได้ จะได้เห็น payload จริง ถ้า server รันอยู่ในเครื่อง ให้เปิดออกสู่ ภายนอกด้วย tunnel อย่าง ngrok หรือ cloudflared

  5. เพิ่ม 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 อาจได้ผลตามมาทีหลัง ตอนนั้นคุณจะได้ webhook COMPLETED ตามปกติ

ระบบไม่ส่ง webhook ในกรณีต่อไปนี้ ดู call เหล่านี้ได้ที่ /calls เท่านั้น

  • call ที่วิเคราะห์ไม่สำเร็จ (ANALYSIS_FAILED)
  • call ที่เป็น EXPIRED
  • call ที่รอโทรอยู่แล้วกลายเป็น CANCELLED เพราะคุณยกเลิก campaign
  • call ที่ COMPLETED แต่ Gowajee ไม่ได้วิเคราะห์ เพราะไม่มีทั้งไฟล์บันทึกเสียงและ transcript หรือ agent หยุดเพราะ error (agentStatus ไม่ใช่ COMPLETED)
POST https://your-server.example.com/gowajee/webhook
{
"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 ดูการลบข้อมูลตามระยะเวลาเก็บรักษา
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) ข้อมูลเดียวกัน แต่เป็นชื่อเก่า
อ่านค่า field ที่กำหนดเอง (ใช้ได้ทั้ง v1 และ v2)
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 นั้นซ้ำ
  • การวิเคราะห์ซ้ำจะไม่เอาผลแบบนี้ไปเขียนทับผลที่ดีอยู่แล้ว

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
}
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 เสมอ

Webhook request ไม่มี signature ถ้าจะให้แน่ใจว่า request มาจาก Gowajee จริง ให้ทำดังนี้

  1. ใช้ HTTPS Gowajee รับ URL แบบ http:// ได้ แต่ข้อมูลจะส่งไปแบบไม่เข้ารหัส
  2. ใส่ secret ที่สุ่มมายาวๆ ไว้ใน URL เช่น https://your-server.example.com/gowajee/webhook/9f2c7e… แล้ว ปฏิเสธ request ที่ path ไม่ตรง
  3. ถือว่า payload เป็นแค่การแจ้งเตือน ถ้าเป็นเรื่องสำคัญ ให้ยืนยันอีกทีด้วยการเรียก GET /api/call/:id ด้วย API key ของคุณ

header User-Agent และ X-Gowajee-Event ช่วยให้ route request ได้ง่ายขึ้น แต่ใครก็ส่ง header เหล่านี้มาได้ จึงใช้พิสูจน์ไม่ได้ว่า request มาจาก Gowajee จริง

ถ้าองค์กรของคุณตั้งนโยบายเก็บข้อมูลไว้ ระบบจะลบข้อมูลเก่าเมื่อครบเวลาที่กำหนด และ 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);
ตารางที่ใช้ในโค้ดด้านบน (PostgreSQL)
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
);