Skip to main content

Server integration

Post-call webhook

The payload delivered after a call completes.

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

FieldTypeNotes
call_idstringGlobally unique. Use as your dedupe key.
deployment_idstringSource campaign. Null for one-off test calls.
from / toE.164Pre-translation. WhatsApp calls carry wa_id here.
channelenumpstn | whatsapp | sip
started_atISO-8601When Finn started dialing or received the call.
answered_atISO-8601 or nullNull when the call never picked up.
duration_secondsintBillable duration, answered_at to ended_at.
outcome.labelstringYour own outcome taxonomy, defined in the prompt.
outcome.confidencefloat 0–1Model confidence in the label.
outcome.extracted_fieldsobjectFree-form, shaped by your prompt design.
sentimentenumpositive | neutral | negative
call_statusenumcompleted | no_answer | busy | failed | voicemail
hangup_partyenumcaller | agent | system
credits_chargedfloatWallet debit for this call.
audience_rowobjectFull 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

SymptomCause and fix
Signature always failsYou parsed JSON before verifying. Verify the raw body.
Duplicate CRM rowsNo dedupe on event.id. Add a unique constraint or seen-set.
Events arrive late or repeatYour endpoint took over 5 seconds. Ack first, process in a queue.
audience_row is nullThe call was inbound. Branch on call_type.
Recording URL returns 403Recording 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.