Skip to main content

Webhooks post-appel

Abonnez-vous aux événements call.completed — transcriptions, enregistrements, résultats.

6 min read

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_at et des gestionnaires idempotents, ne vous fiez pas à un ordre strict.
  • Nouvelles tentatives : 5 tentatives sur environ 10 minutes en cas de 5xx ou 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

ChampTypeRemarques
call_idchaîneUnique au niveau mondial. À utiliser comme clé de déduplication.
deployment_idchaîneCampagne source. Null pour les appels de test ponctuels.
from / toE.164Avant traduction. Les appels WhatsApp utilisent ici wa_id.
channelenumpstn | whatsapp | sip
started_atISO-8601Moment où Finn a lancé la numérotation / reçu l'appel
answered_atISO-8601 / nullNull si l'appel n'a jamais été décroché
duration_secondsintDurée facturable, de answered_at à ended_at
outcome.labelstringVotre taxonomie de résultats personnalisée définie dans le prompt
outcome.confidencefloat 0–1Confiance du modèle dans le libellé
outcome.extracted_fieldsobjectDonnées structurées par appel (format libre, selon la conception du prompt)
sentimentenumpositive | neutral | negative
call_statusenumcompleted | no_answer | busy | failed | voicemail
hangup_partyenumcaller | agent | system
credits_chargedfloatDébit du portefeuille pour cet appel
audience_rowobjectLa 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ômeSolution
La signature échoue systématiquementVous analysez le JSON avant de vérifier. Vérifiez d'abord le corps brut.
Lignes CRM en doubleAbsence de déduplication par event.id. Ajoutez une contrainte d'unicité ou un ensemble de suivi.
Événements tardifsVotre point de terminaison a mis plus de 5 s. Déplacez le traitement vers une file d'attente.
audience_row manquantL'appel était entrant (pas d'audience). Vérifiez d'abord call_type.
Erreur 403 sur l'URL d'enregistrementLes 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énementQuand
call.startedL'opérateur a décroché
call.completedCette page
call.transferredTransfert supervisé vers un humain
deployment.completedCampagne ayant épuisé son audience
wallet.low_balanceSous 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.