Skip to main content

Post-call Webhooks

Subscribe to call.completed events — transcripts, recordings, outcomes.

5 min read

Post-call Webhooks

Fire your server every time a Finn call ends. Receive transcript, recording URL, structured outcome, sentiment, and cost. The single most important webhook for syncing call data into your CRM, analytics, or ops pipeline.


When it fires

  • Event: call.completed
  • Fires: within 5 seconds of the call ending (any reason — picked, missed, voicemail, busy, failed)
  • Order: best-effort. Use created_at + idempotent handlers, don't rely on strict order.
  • Retries: 5 attempts over ~10 minutes on 5xx / timeout. Last attempt logged in the dashboard webhook log.

Register a subscription

Via dashboard

Settings → Integrations → Webhooks → New webhook.

Pick call.completed from the event list. Paste your endpoint URL. Save — Finn shows the signing secret once. Copy it now (you can't see it again).

Via 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"
  }'

Response includes secret — store securely.


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 use wa_id here.
channelenumpstn | whatsapp | sip
started_atISO-8601When Finn initiated dialing / received the call
answered_atISO-8601 / nullNull if the call never picked up
duration_secondsintBillable duration, from answered_at to ended_at
outcome.labelstringYour custom outcome taxonomy from the prompt
outcome.confidencefloat 0–1Model confidence in the label
outcome.extracted_fieldsobjectPer-call structured data (free-form by prompt design)
sentimentenumpositive | neutral | negative
call_statusenumcompleted | no_answer | busy | failed | voicemail
hangup_partyenumcaller | agent | system
credits_chargedfloatWallet debit for this call
audience_rowobjectThe full CSV row dialed (for outbound). Null for inbound.

Signature verification

Finn signs every webhook with HMAC-SHA256. Verify before trusting the payload.

Header: X-Finn-Signature: t=1716391823,v1=abc123...

The t= is the timestamp. The v1= is HMAC-SHA256(secret, t + "." + raw_body).

Node

import crypto from "crypto";

function verifyFinnSignature(
  rawBody: string,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const ts = parseInt(parts.t, 10);
  const sig = parts.v1;
  if (!ts || !sig) return false;

  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false; // replay guard

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Python

import hmac, hashlib, time

def verify_finn_signature(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts.get("t", 0))
    sig = parts.get("v1", "")
    if not ts or not sig: return False
    if abs(time.time() - ts) > tolerance: return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Always verify on the raw request body, not the parsed JSON — re-serialization changes whitespace and breaks the HMAC.


Handling the payload (Node example)

import express from "express";

const app = express();

// raw body required for signature verification
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());

    // Dedupe — idempotent processing
    if (await db.eventExists(event.id)) {
      return res.status(200).send("dup");
    }
    await db.markEventSeen(event.id);

    // Route by event type
    if (event.type === "call.completed") {
      await handleCallCompleted(event.data);
    }

    res.status(200).send("ok");
  },
);

async function handleCallCompleted(call: any) {
  // 1. Update CRM record
  await crm.updateLead(call.audience_row?.phone, {
    last_call_outcome: call.outcome.label,
    last_call_sentiment: call.sentiment,
    last_call_recording: call.recording_url,
  });

  // 2. If qualified, fire a Slack alert
  if (call.outcome.label === "qualified") {
    await slack.notify("#sales-hot-leads", `Hot lead: ${call.audience_row.name}`);
  }

  // 3. Push to data warehouse
  await warehouse.insert("finn_calls", call);
}

Reliability + idempotency

  • Dedupe by event.id — Finn may retry an event we already delivered if your endpoint timed out
  • Return 2xx within 5 seconds — otherwise we treat it as a failure and retry
  • Do the heavy work async — queue the payload, ack immediately
  • Replay from dashboard — failed deliveries appear in Settings → Integrations → Webhooks → Logs with a "Replay" button

Common gotchas

SymptomFix
Signature always failsYou're parsing JSON before verifying. Verify raw body first.
Duplicate CRM rowsNot deduping by event.id. Add a unique-constraint or seen-set.
Late eventsYour endpoint took > 5s. Move processing to a queue.
Missing audience_rowThe call was inbound (no audience). Check call_type first.
Recording URL 403Recording URLs expire after 30 days by default. Mirror to your own storage for long-term archive.

The post-call webhook is one of several. See the full catalog:

EventWhen
call.startedCarrier picked up
call.completedThis page
call.transferredWarm transfer to human
deployment.completedCampaign exhausted audience
wallet.low_balanceBelow configured threshold

Was this page helpful?

Still stuck or have feedback?

Email support@hirefinn.ai or use the chat bubble in the bottom-right corner — it's a Finn that knows the Academy cold.