Skip to content

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.

  1. 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 POST with Content-Type: application/json,
    • reply with any 2xx status 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 3xx counts as a failed delivery.
  2. 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.

  3. 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.
  4. 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.

  5. 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.

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_FAILED call can get its result later. You then receive it as a normal COMPLETED webhook.

No webhook is sent for these. They show up in /calls only:

  • a call whose analysis failed (ANALYSIS_FAILED),
  • an EXPIRED call,
  • a waiting call that became CANCELLED because you cancelled the campaign,
  • a COMPLETED call that Gowajee doesn’t analyze: no recording and no transcript, or the agent stopped with an error (agentStatus is not 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 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.
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.
Reading a custom field (works for v1 and v2)
const fields = callResult?.postCallAnalyticsResult ?? callResult?.customizableAttr ?? {};
const promiseToPay = fields.promise_to_pay?.value; // true

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, callSummary and error.value are "No conversation".
  • funnelFlow can list the agent’s real funnel steps, but value is "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.

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.id and call.sessionId as 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
}
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.

Webhook requests are not signed. To make sure a request really comes from Gowajee:

  1. Use HTTPS. Gowajee accepts an http:// URL, but then the data travels unencrypted.
  2. 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.
  3. Treat the payload as a notification. For anything sensitive, confirm by calling GET /api/call/:id with 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.

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.

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);
Table used above (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
);