Post-call Webhooks
Subscribe to call.completed events — transcripts, recordings, outcomes.
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
| 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 use wa_id here. |
channel | enum | pstn | whatsapp | sip |
started_at | ISO-8601 | When Finn initiated dialing / received the call |
answered_at | ISO-8601 / null | Null if the call never picked up |
duration_seconds | int | Billable duration, from answered_at to ended_at |
outcome.label | string | Your custom outcome taxonomy from the prompt |
outcome.confidence | float 0–1 | Model confidence in the label |
outcome.extracted_fields | object | Per-call structured data (free-form by 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 | The 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
| Symptom | Fix |
|---|---|
| Signature always fails | You're parsing JSON before verifying. Verify raw body first. |
| Duplicate CRM rows | Not deduping by event.id. Add a unique-constraint or seen-set. |
| Late events | Your endpoint took > 5s. Move processing to a queue. |
Missing audience_row | The call was inbound (no audience). Check call_type first. |
| Recording URL 403 | Recording URLs expire after 30 days by default. Mirror to your own storage for long-term archive. |
Related events
The post-call webhook is one of several. See the full catalog:
| Event | When |
|---|---|
call.started | Carrier picked up |
call.completed | This page |
call.transferred | Warm transfer to human |
deployment.completed | Campaign exhausted audience |
wallet.low_balance | Below configured threshold |
Related
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.