What it is
The post-call webhook delivers a call.completed event to your server after every Finn call ends. It carries the transcript URL, recording URL, structured outcome, sentiment, and billing figures. It fires for every terminal call state, not only answered calls: picked, missed, voicemail, busy, and failed all produce an event.
Delivery happens within 5 seconds of the call ending. Ordering is best-effort. Do not assume event N arrives before event N+1 for two calls that ended close together. Sort by created_at and write your handler to be idempotent.
Subscribe
In the dashboard, go to Settings → Integrations → Webhooks → New webhook, select call.completed, and paste your endpoint URL. The signing secret is shown once at creation. If you navigate away without copying it, you cannot retrieve it and have to create a new subscription.
Via the API:
curl https://api.hirefinn.ai/v1/webhooks \
-H "Authorization: Bearer $FINN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/finn/post-call",
"events": ["call.completed"],
"description": "CRM + analytics sync"
}'
The response body contains secret. Store it before discarding the response.
The payload
{
"id": "evt_2H4abc",
"type": "call.completed",
"created_at": "2026-05-22T14:32:14.892Z",
"org_id": "org_8fac17c5",
"data": {
"call_id": "cal_xyz789",
"deployment_id": "dep_abc123",
"finn_id": "fn_def456",
"phone_number_id": "ph_1234",
"from": "+919876543210",
"to": "+918765432100",
"call_type": "outbound",
"channel": "pstn",
"started_at": "2026-05-22T14:30:01.245Z",
"answered_at": "2026-05-22T14:30:04.812Z",
"ended_at": "2026-05-22T14:32:14.123Z",
"duration_seconds": 130,
"ring_seconds": 4,
"outcome": {
"label": "qualified",
"confidence": 0.87,
"extracted_fields": {
"preferred_slot": "2026-05-24T15:00:00+05:30",
"budget": "25000",
"is_decision_maker": true
}
},
"sentiment": "positive",
"csat_score": null,
"call_status": "completed",
"hangup_party": "agent",
"recording_url": "https://recordings.hirefinn.ai/.../cal_xyz789.mp3",
"recording_duration_seconds": 130,
"transcript_url": "https://transcripts.hirefinn.ai/.../cal_xyz789.json",
"credits_charged": 3,
"currency": "INR",
"monetary_value": 10.05,
"audience_id": "aud_xyz789",
"audience_row": {
"name": "Priya M",
"phone": "+919876543210",
"loan_amount": "500000"
},
"metadata": {
"campaign_tag": "may-cohort-3"
}
}
}
Field reference
| Field | Type | Notes |
|---|---|---|
call_id | string | Globally unique. Use as your dedupe key. |
deployment_id | string | Source campaign. Null for one-off test calls. |
from / to | E.164 | Pre-translation. WhatsApp calls carry wa_id here. |
channel | enum | pstn | whatsapp | sip |
started_at | ISO-8601 | When Finn started dialing or received the call. |
answered_at | ISO-8601 or null | Null when the call never picked up. |
duration_seconds | int | Billable duration, answered_at to ended_at. |
outcome.label | string | Your own outcome taxonomy, defined in the prompt. |
outcome.confidence | float 0–1 | Model confidence in the label. |
outcome.extracted_fields | object | Free-form, shaped by your prompt design. |
sentiment | enum | positive | neutral | negative |
call_status | enum | completed | no_answer | busy | failed | voicemail |
hangup_party | enum | caller | agent | system |
credits_charged | float | Wallet debit for this call. |
audience_row | object | Full CSV row dialed on outbound. Null for inbound. |
Fields nullable in practice: answered_at, deployment_id, csat_score, audience_id, audience_row. Read call_type and call_status before dereferencing any of them. A handler that does call.audience_row.name throws on the first inbound call it sees.
For how outcome.label and extracted_fields are produced, see post-call analysis and analysis fields.
Handling it
import express from "express";
const app = express();
app.post(
"/finn/post-call",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["x-finn-signature"] as string;
const ok = verifyFinnSignature(
req.body.toString(),
sig,
process.env.FINN_WEBHOOK_SECRET!,
);
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
if (await db.eventExists(event.id)) {
return res.status(200).send("dup");
}
await db.markEventSeen(event.id);
if (event.type === "call.completed") {
await queue.enqueue("finn-post-call", event.data);
}
res.status(200).send("ok");
},
);
app.listen(3000);
Verify the signature against the raw request body. Parsing to JSON and re-serializing changes whitespace and key order, and the HMAC will not match. See webhook security for the verification implementation.
What goes wrong
| Symptom | Cause and fix |
|---|---|
| Signature always fails | You parsed JSON before verifying. Verify the raw body. |
| Duplicate CRM rows | No dedupe on event.id. Add a unique constraint or seen-set. |
| Events arrive late or repeat | Your endpoint took over 5 seconds. Ack first, process in a queue. |
audience_row is null | The call was inbound. Branch on call_type. |
| Recording URL returns 403 | Recording URLs expire after 30 days by default. Mirror to your own storage for long-term archive. |
Failed deliveries retry 5 times over roughly 10 minutes on 5xx responses and timeouts. Each attempt appears in Settings → Integrations → Webhooks → Logs, where you can replay a delivery. Retry behavior is covered in webhook retries.