Skip to main content

Server integration

Post-call webhook

The call.completed payload, field by field.

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

FieldTypeNotes
call_uuidUUIDThe call's ID. Use it with GET /api/v1/calls/{call_uuid} and, together with event, as your dedupe key.
finn_idUUID or nullThe Finn that handled the call.
deployment_idUUID or nullThe 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.
statusstring or nullFinal call status. More values can be added, so treat it as a string.
disconnect_reasonstring or nullWhy the call ended. More values can be added.
directionstring or nullinbound or outbound.
from_numberstring or nullThe calling number.
to_numberstring or nullThe 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_secondsinteger or nullCall length, rounded to whole seconds.
user_sentimentstring or nullOverall sentiment of the person called.
call_successfulboolean or nullWhether the call met its goal, according to analysis.
created_atISO 8601 or nullWhen the call record was created.
updated_atISO 8601 or nullWhen the call record was last updated.
recording_urlstring or nullSigned 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.
analysisarrayYour 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:

FieldTypeNotes
question_namestringThe analysis field's name.
question_typestringThe field's type, as set up on the Finn.
answerstring or nullThe extracted answer. It's always a string, even for Yes/No and Number questions, so convert it yourself.
needs_reviewbooleanThe extraction was uncertain and a person should check it.
reasoningstring or nullWhy the model chose this answer.
extracted_atISO 8601When 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

SymptomCause and fix
The signature always failsYou parsed the JSON before verifying. Verify the raw body.
Duplicate CRM rowsYou aren't removing duplicates on call_uuid. Add a unique constraint.
Some calls never arriveAll 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 callThe 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 matchanswer is a string, so "true" === true is false. Convert it first.
You need the transcriptIt isn't in the payload. Open the call in the dashboard.
recording_url returns an errorThe 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.