What it is
The post-call webhook is the call.completed event. Finn sends it to your webhook endpoints once for every call after it ends: calls from deployments, calls placed with POST /api/v1/calls, and calls nobody answered. It contains the call's final status, the numbers involved, the duration, the sentiment and the results of your analysis fields.
It doesn't include a transcript or billing figures. It does include recording_url, a signed link to the recording that works for 1 hour. To get billing, read the call with GET /api/v1/calls/{call_uuid}, which returns a billing block. See api-calls.
The event isn't sent at hang-up. Calls that weren't answered, and calls on a Finn with no analysis fields, are usually sent within about 5 minutes of the call ending. Answered calls on a Finn with analysis fields are sent as soon as the analysis is saved, usually within about 5 minutes. If no analysis has arrived after about an hour, the event is sent anyway with whatever analysis exists, which can be none. See webhooks. Don't assume calls arrive in the order they ended. Use data.updated_at if you need to order them.
Subscribe
In the dashboard, go to Settings → Integrations → Webhooks, click Add endpoint and paste a public https:// URL. Every endpoint receives call.completed. The signing secret is shown once, when you add the endpoint. If you don't copy it then, delete the endpoint and add it again.
You can't create endpoints with an API key. See api-webhooks.
The payload
{
"event": "call.completed",
"created_at": "2026-09-18T11:14:09.000Z",
"data": {
"call_uuid": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"finn_id": "8f14e45f-ceea-467a-9f6a-1c0e5b2a77d1",
"deployment_id": "b2d4a611-99c7-42f0-8a3e-1f5d6c0e9a72",
"status": "completed",
"disconnect_reason": "hangup",
"direction": "outbound",
"from_number": "+918045550199",
"to_number": "+919876543210",
"duration_seconds": 130,
"user_sentiment": "positive",
"call_successful": true,
"created_at": "2026-09-18T11:11:59.000Z",
"updated_at": "2026-09-18T11:14:08.000Z",
"recording_url": "https://storage.example.com/recordings/3f2504e0-4f89-11d3-9a0c-0305e82c3301.wav?sig=...",
"analysis": [
{
"question_name": "preferred_slot",
"question_type": "Text",
"answer": "Saturday 3pm",
"needs_review": false,
"reasoning": "Caller proposed Saturday afternoon and confirmed 3pm.",
"extracted_at": "2026-09-18T11:14:07.000Z"
},
{
"question_name": "is_decision_maker",
"question_type": "Yes/No",
"answer": "Yes",
"needs_review": false,
"reasoning": "Caller said they sign off on the purchase.",
"extracted_at": "2026-09-18T11:14:07.000Z"
}
]
}
}
Field reference
| Field | Type | Notes |
|---|---|---|
call_uuid | UUID | The call's ID. Use it with GET /api/v1/calls/{call_uuid} and, together with event, as your dedupe key. |
finn_id | UUID or null | The Finn that handled the call. |
deployment_id | UUID or null | The deployment the call belongs to: the same id that GET /api/v1/deployments returns. null for calls that don't belong to a deployment, such as calls placed with POST /api/v1/calls. |
status | string or null | Final call status. More values can be added, so treat it as a string. |
disconnect_reason | string or null | Why the call ended. More values can be added. |
direction | string or null | inbound or outbound. |
from_number | string or null | The calling number. |
to_number | string or null | The number that was called, in E.164 format (+ and the country code). For calls placed with the API, this is the to_number you sent combined with your country_code, so "4155550142" with "1" arrives as "+14155550142". |
duration_seconds | integer or null | Call length, rounded to whole seconds. |
user_sentiment | string or null | Overall sentiment of the person called. |
call_successful | boolean or null | Whether the call met its goal, according to analysis. |
created_at | ISO 8601 or null | When the call record was created. |
updated_at | ISO 8601 or null | When the call record was last updated. |
recording_url | string or null | Signed link to the recording, valid for 1 hour after the event is sent. Call GET /api/v1/calls/{call_uuid} for a fresh link. Null when the call has no recording. |
analysis | array | Your analysis fields, newest extraction first. Empty if none are set up. Can also be empty for a call that wasn't answered, or when analysis hadn't arrived after about an hour. |
Each analysis entry:
| Field | Type | Notes |
|---|---|---|
question_name | string | The analysis field's name. |
question_type | string | The field's type, as set up on the Finn. |
answer | string or null | The extracted answer. It's always a string, even for Yes/No and Number questions, so convert it yourself. |
needs_review | boolean | The extraction was uncertain and a person should check it. |
reasoning | string or null | Why the model chose this answer. |
extracted_at | ISO 8601 | When the answer was extracted. |
Every field in data except call_uuid and analysis can be null. That happens when the call record wasn't available when the payload was built. Check before you use a value. If the fields you need are null, read the call with GET /api/v1/calls/{call_uuid}.
Handling it
import express from "express";
const app = express();
app.post(
"/finn/post-call",
express.raw({ type: "application/json" }),
async (req, res) => {
const raw = req.body.toString("utf8");
const sig = req.headers["x-finn-signature"];
if (typeof sig !== "string" || !verifyFinnSignature(raw, sig, process.env.FINN_WEBHOOK_SECRET!)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(raw);
if (event.event !== "call.completed") return res.status(200).send("ignored");
const key = `${event.event}:${event.data.call_uuid}`;
if (await db.eventExists(key)) return res.status(200).send("dup");
await db.markEventSeen(key);
await queue.enqueue("finn-post-call", event.data);
res.status(200).send("ok");
},
);
app.listen(3000);
Verify the signature against the raw request body. If you parse the JSON and serialize it again, the whitespace and key order change and the HMAC won't match. webhook-security has verifyFinnSignature.
What goes wrong
| Symptom | Cause and fix |
|---|---|
| The signature always fails | You parsed the JSON before verifying. Verify the raw body. |
| Duplicate CRM rows | You aren't removing duplicates on call_uuid. Add a unique constraint. |
| Some calls never arrive | All 3 attempts failed, for example because your endpoint was down or slower than 10 seconds. Finn doesn't redeliver. Reconcile with GET /api/v1/calls/{call_uuid}. |
analysis is empty on an answered call | The Finn has no analysis fields, or analysis hadn't arrived about an hour after the call, so the event went out without it. Analysis that arrives later is in GET /api/v1/calls/{call_uuid}. |
answer comparisons never match | answer is a string, so "true" === true is false. Convert it first. |
| You need the transcript | It isn't in the payload. Open the call in the dashboard. |
recording_url returns an error | The link expired after 1 hour. Call GET /api/v1/calls/{call_uuid} for a fresh one. |
Failed deliveries are retried twice, 1 second and then 2 seconds after the previous attempt, on timeouts, connection errors, 429, 5xx, 3xx (redirects aren't followed) and response bodies over 64 KB. Each endpoint's 100 most recent deliveries appear under Deliveries in Settings → Integrations → Webhooks. You can't replay a delivery. See webhook retries.