Get results by webhook
With a webhook, Gowajee pushes each call’s result to your server as soon as it is ready: one POST with a JSON
body of type CALL_RESULT. It is faster than polling, but a result can be lost if your server is
down for longer than the retry window. That’s why polling is the recommended default, and the webhook is best used
together with it.
Set up your webhook
Section titled “Set up your webhook”-
Build an endpoint on your server. It must:
- be reachable from the internet. Use HTTPS: Gowajee doesn’t enforce it, but a plain
http://URL sends customer data unencrypted, - accept
POSTwithContent-Type: application/json, - reply with any
2xxstatus within 10 seconds, and do the real work afterwards (for example in a queue), - answer at the URL itself. Redirects are not followed, so a
3xxcounts as a failed delivery.
- be reachable from the internet. Use HTTPS: Gowajee doesn’t enforce it, but a plain
-
Put a secret in the path, for example
https://your-server.example.com/gowajee/webhook/9f2c7e1b…. Requests are not signed, so the secret is how you know a request came from Gowajee. See Securing your endpoint. -
Save the URL in the dashboard. Sign in as an Owner or Admin, open System → Developers, paste the URL into Webhook URL, and save.
- There is one URL per organization. It receives results from every campaign.
- A change applies to the next result that is sent.
-
Test it. Start a small campaign that calls your own phone. Before your code is ready, you can paste a request-inspector URL (such as webhook.site) as the webhook URL to see the raw payload. For a local server, expose it with a tunnel such as ngrok or cloudflared.
-
Add polling as a safety net. Run the polling sync every few minutes too, so a result that didn’t arrive by webhook is still picked up.
When is it sent?
Section titled “When is it sent?”| Moment | call.callStatus |
callResult |
|---|---|---|
| A call reached the customer and the conversation has been analyzed | COMPLETED |
The analysis result, or the fallback result |
| A call attempt ended without a conversation | NO_ANSWER, BUSY, FAILED, or CANCELLED because the call waited too long in the queue |
null |
| Gowajee re-analyzed a call you already received (see below) | COMPLETED |
The new result, with isReAnalysis: true |
- You get one webhook per call attempt. A recipient called 3 times can produce 3 webhooks. Group them by
recipient.tel. - An
ANALYSIS_FAILEDcall can get its result later. You then receive it as a normalCOMPLETEDwebhook.
No webhook is sent for these. They show up in /calls only:
- a call whose analysis failed (
ANALYSIS_FAILED), - an
EXPIREDcall, - a waiting call that became
CANCELLEDbecause you cancelled the campaign, - a
COMPLETEDcall that Gowajee doesn’t analyze: no recording and no transcript, or the agent stopped with an error (agentStatusis notCOMPLETED).
Payload
Section titled “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 } }}Fields
Section titled “Fields”| Field | Type | Description |
|---|---|---|
type |
string | Always "CALL_RESULT". |
payload.isReAnalysis |
boolean | Always present. true when this is a repeat result for a call you already received. See Re-analysis. |
payload.agentName / campaignName |
string | Names, for display. |
payload.call.id |
string (UUID) | The call ID. Use it as your idempotency key. It is the same id as in /calls and GET /api/call/:id. |
payload.call.sessionId |
string | The session ID of this call attempt. Useful when you contact Gowajee support. |
payload.call.callStatus |
string | See Call status. |
payload.call.callStartedAt |
string (UTC) or null |
When the conversation started. null if the customer never answered. |
payload.call.callEndedAt |
string (UTC) or null |
When the attempt ended. It is set for unanswered calls too. |
payload.call.callDuration |
numeric string, or null |
Seconds, with 3 decimals, as a string such as "120.120". Parse it with parseFloat/float(). |
payload.call.errorMessage |
string or null |
Why a call failed, when known. |
payload.recipient |
object | The recipient as it is when the webhook is sent: tel, customerName, callAttempt, latestCallStatus, agentParams. callAttempt is a running count of the attempts that have ended so far. It isn’t this call’s attempt number, so don’t use it to order webhooks. |
payload.agent |
object | id, name, paramsSchema. |
payload.campaign |
object | The campaign’s settings when the webhook was sent. retryInterval is in milliseconds. |
payload.callResult |
object or null |
The analysis. null when there is no result, for example on NO_ANSWER. See callResult below. |
payload.recordUrl |
string or null |
A link to download the recording. It expires in 1 hour or less. Save the file, or get a fresh link later with /record-url. |
payload.transcriptFileUrl |
string or null |
A link to the transcript JSON. It also expires in 1 hour or less. |
payload.dataExpired |
object | audio, personalData, output booleans. See Data retention. |
callResult
Section titled “callResult”| Field | Description |
|---|---|
version |
"v2" for current agents. Older agents send "v1". |
fixedAttr.callSummary |
A short summary of the conversation, usually in Thai. |
fixedAttr.funnel |
Present when the agent defines a funnel. funnelFlow lists the steps in order, and value is the furthest step reached. |
postCallAnalyticsResult (v2) |
Your custom analysis fields. Each key is an object { type, value, required, description?, enumVariant? }. Read .value. |
customizableAttr (v1) |
The same thing under the old name. |
const fields = callResult?.postCallAnalyticsResult ?? callResult?.customizableAttr ?? {};const promiseToPay = fields.promise_to_pay?.value; // trueWhen the analysis could not run
Section titled “When the analysis could not run”Sometimes Gowajee can’t analyze a conversation. The call is still COMPLETED, and callResult has this fixed shape
instead of your fields:
{ "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" } }}- When nothing was said in the call,
callSummaryanderror.valueare"No conversation". funnelFlowcan list the agent’s real funnel steps, butvalueis"Unknown".- Check for
postCallAnalyticsResult.error. Your own fields are not there. Ask Gowajee to re-analyze the call. - A re-analysis never replaces a good result with this shape.
Re-analysis: isReAnalysis
Section titled “Re-analysis: isReAnalysis”Gowajee can run the analysis again on calls that already finished, for example after improving your agent’s analysis
questions at your request. For each re-analyzed call you receive another CALL_RESULT with:
- the same
call.idandcall.sessionIdas before, - the new
callResult, isReAnalysis: true.
How to handle it: save results with an upsert keyed on call.id. The newer result then replaces the
older one. If your system must not trigger a business action twice (such as sending an SMS), check isReAnalysis
before acting:
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}Delivery and retries
Section titled “Delivery and retries”| Method | POST, Content-Type: application/json |
| Headers | User-Agent: Gowajee-Webhook/1.0 and X-Gowajee-Event: CALL_RESULT |
| Success | Any 2xx status |
| Timeout | 10 seconds per attempt. No reply in time counts as a failed attempt. |
| Redirects | Not followed. A 3xx counts as a failed attempt. Save the final URL in the dashboard. |
| Retries | 3 attempts in total. Gowajee waits 1 second before the 2nd attempt and 2 seconds before the 3rd. After that the result is not sent again. The whole window is a few seconds, or about 35 seconds if every attempt times out. |
| Order | Not guaranteed. A NO_ANSWER for attempt 2 can arrive after something from attempt 1. |
| Duplicates | Possible. Always upsert on call.id. |
Securing your endpoint
Section titled “Securing your endpoint”Webhook requests are not signed. To make sure a request really comes from Gowajee:
- Use HTTPS. Gowajee accepts an
http://URL, but then the data travels unencrypted. - Put a long random secret in the URL, for example
https://your-server.example.com/gowajee/webhook/9f2c7e…, and reject requests whose path doesn’t match. - Treat the payload as a notification. For anything sensitive, confirm by calling
GET /api/call/:idwith your API key.
The User-Agent and X-Gowajee-Event headers help you route requests, but anyone can send them, so they don’t prove
that a request came from Gowajee.
Data retention
Section titled “Data retention”If your organization has a data-retention policy, old data is removed after a set time. The webhook then says so:
| Flag | When true |
|---|---|
dataExpired.audio |
recordUrl is null. The recording was deleted. |
dataExpired.personalData |
recipient.tel is masked, customerName is "", and transcriptFileUrl is null. agentParams keeps every key, but each value becomes null. |
dataExpired.output |
callResult keeps every key, but each value becomes null, including version. |
In both cases, keys that start with metadata__ keep their values.
This mostly affects re-analysis results for old calls.
Example receiver
Section titled “Example receiver”A complete receiver. It checks the secret, replies straight away, then processes the result in the background:
- Upserts on
call.id, so duplicates and re-analysis are safe. - Triggers your follow-up action only on the first result (
isReAnalysis: false). - Downloads the recording within the hour, before the link expires.
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);