8. Intégrations — Webhooks sortants

Les webhooks sortants permettent à NoviaMind de notifier vos propres systèmes dès que vos données CRM changent — sans avoir à interroger l'API en continu. Usages typiques : déclencher un workflow n8n / Make / Zapier à la capture d'un nouveau prospect, ou maintenir un outil interne synchronisé.

Accédez-y depuis Intégrations → Webhooks sortants dans la section CRM.


8.9 Événements disponibles

ÉvénementEnvoyé quand
lead.createdUn prospect est créé
lead.updatedUn prospect est mis à jour
property.createdUn bien est créé
property.updatedUn bien est mis à jour
realtor.createdUn agent est créé
realtor.updatedUn agent est mis à jour

Un webhook s'abonne à un ou plusieurs événements — seuls ceux-ci déclenchent une livraison vers votre URL.


8.10 Créer un webhook

  1. Allez dans Intégrations → Webhooks sortants
  2. Cliquez sur « Ajouter un webhook »
  3. Saisissez votre URL de rappel (obligatoirement en https:// en production ; http:// n'est accepté que pour les tests locaux, par ex. un tunnel ngrok)
  4. Ajoutez éventuellement une description (ex. « workflow n8n production »)
  5. Sélectionnez les événements auxquels vous abonner
  6. Cliquez sur « Enregistrer »

⚠️ Enregistrez immédiatement le secret de signature. Après la création, NoviaMind affiche le secret (whsec_...) une seule et unique fois. Toute consultation ultérieure le montre masqué. En cas de perte, supprimez le webhook et créez-en un nouveau.

💡 Les URL pointant vers des adresses réseau privées ou internes sont rejetées pour des raisons de sécurité.


8.11 Ce que reçoit votre point de terminaison

Chaque livraison est une requête HTTP POST avec un corps JSON :

{
  "id": "d3f5c9a2-...",
  "event": "lead.created",
  "team_id": "b81e07c4-...",
  "created_at": "2026-07-30T14:20:11.000Z",
  "data": { "...": "l'entité créée ou mise à jour — la forme dépend de `event`, voir ci-dessous" }
}

Et ces en-têtes :

En-têteContenu
X-Webhook-IdIdentifiant unique de la livraison (aussi le id du corps)
X-Webhook-EventNom de l'événement (ex. lead.created)
X-Webhook-Signaturesha256=<hex> — signature HMAC-SHA256 du corps brut

Format de data par entité

data n'est pas un DTO de réponse trié sur le volet — c'est la ligne prospect/bien/agent complète telle qu'elle est stockée. Elle peut donc contenir plus de champs que ceux documentés ci-dessous (traitez les champs inconnus comme compatibles avec les évolutions futures, ne les rejetez pas), et tout champ nullable que vous n'utilisez pas vaut simplement null.

lead.created / lead.updateddata est le prospect. Capture réelle (issue d'un appel avec l'agent IA, secrets/données personnelles raccourcis, notes et les blocs de collecte de données IA dans custom_fields / raw_data tronqués par souci de concision — en pratique notes contient le résumé complet de l'appel et custom_fields.* une entrée par champ de collecte configuré) :

{
  "id": "d80029a4-8324-4fad-afe2-ad1af76319fe",
  "team_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "source_provider": "noviamind",
  "external_reference": "conv-XXX",
  "first_name": "Didier",
  "last_name": "Deschamps",
  "email": null,
  "phone": "+33618276741",
  "mobile_phone": null,
  "company": null,
  "job_title": null,
  "status": "new",
  "source_channel": "ai_agent",
  "source_channel_detail": null,
  "notes": "L'utilisateur, Didier Deschamps, a contacté l'agent Giulia pour l'achat d'un T3 à Saint-Jean-Pied-de-Port et a demandé à être rappelé par son conseiller habituel. [...tronqué — résumé complet de l'appel, peut inclure les appels précédents ajoutés à la suite]",
  "tags": ["rent", "ai_agent_lead"],
  "assigned_realtor_external_id": null,
  "interested_property_ids": [],
  "priority": "medium",
  "score": 0,
  "address": null,
  "city": null,
  "postal_code": null,
  "country": null,
  "budget_min": null,
  "budget_max": 8650234,
  "preferred_contact_method": null,
  "next_follow_up_at": null,
  "last_interaction_at": "2026-07-31T14:55:48.316+00:00",
  "sms_consent": false,
  "target_zip_code": "64220",
  "transaction_type": "sale",
  "target_rooms": 3,
  "surface_min_sqm": 20,
  "preferred_area_text": "Saint-Jean-Pied-de-Port",
  "rental_income_3x": null,
  "rental_guarantee_type": "unknown",
  "properties_presented_count": 0,
  "conversion_outcome": "callback_scheduled",
  "visit_datetime_local": null,
  "call_language": "Français",
  "customer_sentiment": "positive",
  "requested_callback": true,
  "disqualifier_reason": "other",
  "custom_fields": {
    "agent_name": "Agence du Pays Basque - Giulia",
    "transaction_type": {
      "value": "sale",
      "rationale": "L'utilisateur a explicitement déclaré vouloir acheter, ce qui correspond à une transaction de type « sale ».",
      "data_collection_id": "transaction_type"
    },
    "requested_callback": {
      "value": true,
      "rationale": "L'utilisateur a explicitement demandé à être rappelé.",
      "data_collection_id": "requested_callback"
    }
  },
  "raw_data": {
    "data_collection_results": {
      "lead_full_name": {
        "value": "Didier Deschamps",
        "rationale": "L'utilisateur a clairement indiqué son nom complet.",
        "data_collection_id": "lead_full_name"
      }
    }
  },
  "provider_created_at": null,
  "provider_updated_at": null,
  "crm_pushed_at": "2026-07-31T12:30:10.829+00:00",
  "created_at": "2026-06-26T16:42:06.236+00:00",
  "updated_at": "2026-07-31T14:55:48.352+00:00"
}

💡 Les identifiants internes de suivi d'appel sont retirés avant que la charge utile ne quitte NoviaMind — vous ne verrez jamais de champ conversation_ids, même s'il s'agit d'une vraie colonne de l'enregistrement sous-jacent.

💡 custom_fields et raw_data.data_collection_results proviennent tous deux de l'extraction de champs par l'IA et se recoupent largement (raw_data est l'extraction telle que capturée à l'origine ; custom_fields est ce qui est réellement persisté sur le prospect). Chaque entrée porte un value, un rationale (pourquoi l'IA a extrait cette valeur) et un data_collection_id — certaines entrées portent aussi un bloc json_schema décrivant le type/l'énumération attendus du champ. value vaut fréquemment null lorsque l'IA n'a rien trouvé à extraire pour ce champ lors de l'appel.

property.created / property.updateddata est le bien :

{
  "id": "9c8b7a6d-...",
  "team_id": "b81e07c4-...",
  "is_sale": true,
  "is_rent": false,
  "property_type": "apartment",
  "surface_sqm": 65,
  "price_eur": 320000,
  "rooms": 3,
  "address": "12 Rue de Rivoli",
  "city": "Paris",
  "zip_code": "75004",
  "title": "Bright 3-room apartment near the Seine",
  "tags": ["renovated"],
  "created_at": "2026-07-30T14:20:11.000Z",
  "updated_at": "2026-07-30T14:20:11.000Z"
}

realtor.created / realtor.updateddata est l'agent :

{
  "id": "1e2d3c4b-...",
  "team_id": "b81e07c4-...",
  "first_name": "Marc",
  "last_name": "Dupont",
  "email": "[email protected]",
  "phone": "+33698765432",
  "role": "agent",
  "status": "active",
  "license_number": "CPI-1234",
  "service_areas": ["Paris 11e", "Paris 12e"],
  "created_at": "2026-07-30T14:20:11.000Z",
  "updated_at": "2026-07-30T14:20:11.000Z"
}

💡 Les livraisons webhook.test (via le bouton « Envoyer un test ») ne correspondent à aucune des formes ci-dessus — data vaut simplement { "message": "This is a test webhook delivery from NoviaMind." }.

Vérifier la signature

Calculez un HMAC-SHA256 des octets bruts du corps de la requête (et non de l'objet re-sérialisé/parsé — une différence d'ordre des clés ou d'espaces suffit à faire échouer la comparaison) avec votre secret de signature, puis comparez le résultat à l'en-tête X-Webhook-Signature avec une comparaison à temps constant. Exemple Node/Express, en supposant que la route a accès au corps brut (par ex. express.raw({ type: 'application/json' }) monté uniquement sur cette route, avant tout parseur de corps JSON) :

const crypto = require('crypto');

function isValidSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader?.startsWith('sha256=')) return false;

  const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const expected = Buffer.from(expectedHex, 'utf8');
  const received = Buffer.from(signatureHeader.slice('sha256='.length), 'utf8');

  // timingSafeEqual lève une exception si les buffers n'ont pas la même longueur — comparez d'abord les longueurs.
  if (expected.length !== received.length) return false;

  return crypto.timingSafeEqual(expected, received);
}

app.post('/webhooks/noviamind', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = isValidSignature(
    req.body,
    req.headers['x-webhook-signature'],
    process.env.WEBHOOK_SECRET,
  );
  if (!valid) return res.status(401).send('invalid signature');

  const event = JSON.parse(req.body); // req.body est ici un Buffer — ne le parsez qu'après vérification
  // ... traitez event.event / event.data
  res.sendStatus(200);
});

💡 Dans n8n, le nœud Webhook vous donne accès au corps brut via son option de données binaires/brutes — vérifiez-le dans un nœud Code avec la même logique avant de faire confiance à event.data, ou gardez simplement l'URL secrète si la vérification de signature n'est pas nécessaire pour votre usage.

Votre point de terminaison doit répondre avec un statut 2xx en moins de 10 secondes. Toute autre réponse compte comme un échec.


8.12 Tester un webhook

Cliquez sur « Envoyer un test » sur la ligne d'un webhook. NoviaMind envoie un événement synthétique webhook.test, signé exactement comme une livraison réelle, et vous affiche le statut HTTP renvoyé par votre point de terminaison — idéal pour valider votre workflow n8n ou votre vérification de signature avant la mise en production.


8.13 Nouvelles tentatives, échecs et désactivation automatique

  • Chaque événement est livré avec jusqu'à 5 tentatives et un backoff exponentiel.
  • La ligne du webhook affiche le statut de la dernière livraison et le nombre d'échecs consécutifs.
  • Après 20 échecs consécutifs, le webhook est désactivé automatiquement afin qu'une URL morte cesse de consommer des tentatives de livraison. Corrigez votre point de terminaison, puis cliquez sur « Réactiver ».
  • Toute livraison réussie remet le compteur d'échecs à zéro.

Historique des livraisons

Cliquez sur « Historique » pour consulter les dernières livraisons (jusqu'à 50, conservées 30 jours) : horodatage, événement, statut HTTP et numéro de tentative. Seules les métadonnées de résultat sont conservées — jamais le contenu de la charge utile.


8.14 Gérer les webhooks

ActionQui peut le faire
Consulter les webhooks et l'historiqueTous les membres de l'équipe
Créer, modifier, tester, activer/désactiverAdministrateurs et membres réguliers
SupprimerAdministrateurs uniquement
  • Activer / Désactiver : suspendez les livraisons sans perdre la configuration (interrupteur sur chaque ligne).
  • Modifier : changez à tout moment l'URL, la description ou les événements abonnés. Le secret de signature ne change jamais.
  • Renouveler le secret : supprimez le webhook et créez-en un nouveau.

💡 Les développeurs trouveront la référence API complète (points de terminaison, schémas, détails de signature) dans la documentation API accessible depuis la fenêtre des webhooks.