Webhooks post-appel
Abonnez-vous aux événements call.completed — transcriptions, enregistrements, résultats.
Webhooks post-appel
Appelez votre serveur à chaque fin d'appel Finn. Recevez la transcription, l'URL de l'enregistrement, le résultat structuré, le sentiment et le coût. Le webhook le plus important pour synchroniser les données d'appel avec votre CRM, vos analyses ou votre chaîne d'exploitation.
Quand il se déclenche
- Événement :
call.completed - Déclenchement : dans les 5 secondes suivant la fin de l'appel (quelle qu'en soit la raison — décroché, manqué, messagerie vocale, occupé, échec)
- Ordre : au mieux. Utilisez
created_atet des gestionnaires idempotents, ne vous fiez pas à un ordre strict. - Nouvelles tentatives : 5 tentatives sur environ 10 minutes en cas de
5xxou de dépassement de délai. La dernière tentative est consignée dans le journal des webhooks du tableau de bord.
Enregistrer un abonnement
Depuis le tableau de bord
Paramètres → Intégrations → Webhooks → Nouveau webhook.
Choisissez call.completed dans la liste des événements. Collez l'URL de votre point de terminaison. Enregistrez — Finn affiche la clé de signature une seule fois. Copiez-la immédiatement (elle ne sera plus visible).
Via l'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"
}'
La réponse contient secret — conservez-la en lieu sûr.
Charge utile
{
"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"
}
}
}
Référence des champs
| Champ | Type | Remarques |
|---|---|---|
call_id | chaîne | Unique au niveau mondial. À utiliser comme clé de déduplication. |
deployment_id | chaîne | Campagne source. Null pour les appels de test ponctuels. |
from / to | E.164 | Avant traduction. Les appels WhatsApp utilisent ici wa_id. |
channel | enum | pstn | whatsapp | sip |
started_at | ISO-8601 | Moment où Finn a lancé la numérotation / reçu l'appel |
answered_at | ISO-8601 / null | Null si l'appel n'a jamais été décroché |
duration_seconds | int | Durée facturable, de answered_at à ended_at |
outcome.label | string | Votre taxonomie de résultats personnalisée définie dans le prompt |
outcome.confidence | float 0–1 | Confiance du modèle dans le libellé |
outcome.extracted_fields | object | Données structurées par appel (format libre, selon la conception du prompt) |
sentiment | enum | positive | neutral | negative |
call_status | enum | completed | no_answer | busy | failed | voicemail |
hangup_party | enum | caller | agent | system |
credits_charged | float | Débit du portefeuille pour cet appel |
audience_row | object | La ligne CSV complète composée (pour les appels sortants). Null pour les appels entrants. |
Vérification de la signature
Finn signe chaque webhook avec HMAC-SHA256. Vérifiez-la avant de faire confiance au payload.
En-tête : X-Finn-Signature: t=1716391823,v1=abc123...
Le t= est l'horodatage. Le v1= est 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)
Vérifiez toujours sur le corps brut de la requête, et non sur le JSON parsé — la re-sérialisation modifie les espaces et casse le HMAC.
Traitement du payload (exemple Node)
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);
}
Fiabilité et idempotence
- Dédupliquez par
event.id— Finn peut réémettre un événement déjà livré si votre endpoint a expiré - Renvoyez un code 2xx en moins de 5 secondes — sinon nous considérons l'appel comme un échec et réessayons
- Effectuez le travail lourd de façon asynchrone — mettez la charge utile en file d'attente, accusez réception immédiatement
- Rejeu depuis le tableau de bord — les livraisons échouées apparaissent dans Paramètres → Intégrations → Webhooks → Journaux avec un bouton « Rejouer »
Pièges courants
| Symptôme | Solution |
|---|---|
| La signature échoue systématiquement | Vous analysez le JSON avant de vérifier. Vérifiez d'abord le corps brut. |
| Lignes CRM en double | Absence de déduplication par event.id. Ajoutez une contrainte d'unicité ou un ensemble de suivi. |
| Événements tardifs | Votre point de terminaison a mis plus de 5 s. Déplacez le traitement vers une file d'attente. |
audience_row manquant | L'appel était entrant (pas d'audience). Vérifiez d'abord call_type. |
| Erreur 403 sur l'URL d'enregistrement | Les URL d'enregistrement expirent après 30 jours par défaut. Copiez-les vers votre propre stockage pour un archivage à long terme. |
Événements associés
Le webhook post-appel n'est qu'un webhook parmi d'autres. Consultez le catalogue complet :
| Événement | Quand |
|---|---|
call.started | L'opérateur a décroché |
call.completed | Cette page |
call.transferred | Transfert supervisé vers un humain |
deployment.completed | Campagne ayant épuisé son audience |
wallet.low_balance | Sous le seuil configuré |
Associé
Was this page helpful?
Still stuck or have feedback?
Email [email protected] or use the chat bubble in the bottom-right corner — it's a Finn that knows the Academy cold.