8. Integrations — Outbound Webhooks

Outbound webhooks let NoviaMind notify your own systems the moment your CRM data changes — instead of you polling the API. Typical uses: triggering an n8n / Make / Zapier workflow when a new prospect is captured, or keeping an in-house tool in sync.

Access them from Integrations → Outbound Webhooks in the CRM section.


8.9 Available Events

EventSent when
lead.createdA prospect is created
lead.updatedA prospect is updated
property.createdA property is created
property.updatedA property is updated
realtor.createdA realtor is created
realtor.updatedA realtor is updated

A webhook subscribes to one or more events — only those trigger a delivery to your URL.


8.10 Creating a Webhook

  1. Go to Integrations → Outbound Webhooks
  2. Click on "Add webhook"
  3. Enter your callback URL (must be https:// in production; http:// is accepted only for local testing, e.g. an ngrok tunnel)
  4. Optionally add a description (e.g. "n8n production workflow")
  5. Select the events to subscribe to
  6. Click on "Save"

⚠️ Store the signing secret immediately. After creation, NoviaMind shows the secret (whsec_...) once and only once. Every later view shows it masked. If you lose it, delete the webhook and create a new one.

💡 URLs pointing to private or internal network addresses are rejected for security reasons.


8.11 What Your Endpoint Receives

Each delivery is an HTTP POST with a JSON body:

{
  "id": "d3f5c9a2-...",
  "event": "lead.created",
  "team_id": "b81e07c4-...",
  "created_at": "2026-07-30T14:20:11.000Z",
  "data": { "...": "the created or updated entity — shape depends on `event`, see below" }
}

And these headers:

HeaderContent
X-Webhook-IdUnique delivery ID (also the id in the body)
X-Webhook-EventEvent name (e.g. lead.created)
X-Webhook-Signaturesha256=<hex> — HMAC-SHA256 signature of the raw body

data format per entity

data is not a curated response DTO — it's the full lead/property/realtor row as stored, so it can carry more fields than are documented below (treat unknown fields as forward-compatible, don't reject on them) and every nullable field you don't use is simply null.

lead.created / lead.updateddata is the lead. Real capture (from an AI-agent call, secrets/PII shortened, notes and the AI data-collection blocks in custom_fields / raw_data truncated for length — in practice notes is the full call summary and custom_fields.* has one entry per configured collection field):

{
  "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. [...truncated — full call summary, can include prior calls appended]",
  "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"
}

💡 Internal call-tracking identifiers are stripped before this leaves NoviaMind — you'll never see a conversation_ids field, even though it's a real column on the underlying record.

💡 custom_fields and raw_data.data_collection_results both come from the AI's field extraction and largely overlap (raw_data is the extraction as originally captured; custom_fields is what's actually persisted to the lead). Each entry carries value, rationale (why the AI extracted that value), and data_collection_id — some entries also carry a json_schema block describing the field's expected type/enum. value is frequently null when the AI found nothing to extract for that field on the call.

property.created / property.updateddata is the property:

{
  "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 is the realtor:

{
  "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"
}

💡 webhook.test deliveries (from the "Send test" button) don't match any of the shapes above — data is just { "message": "This is a test webhook delivery from NoviaMind." }.

Verifying the signature

Compute an HMAC-SHA256 of the raw request body bytes (not the re-serialized/parsed object — key order or whitespace differences will make the signature not match) with your signing secret, and compare it to the X-Webhook-Signature header using a constant-time comparison. Node/Express example, assuming the route has raw-body access (e.g. express.raw({ type: 'application/json' }) mounted only on this route, before any JSON body parser):

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');

  // Different-length buffers make timingSafeEqual throw — compare lengths first.
  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 is a Buffer here — parse only after verifying
  // ... handle event.event / event.data
  res.sendStatus(200);
});

💡 In n8n, the Webhook node gives you the raw body via the node's binary/raw data option — verify it in a Code node with the same logic before trusting event.data, or simply keep the URL secret if signature verification isn't required for your use case.

Your endpoint should respond with a 2xx status within 10 seconds. Any other response counts as a failure.


8.12 Testing a Webhook

Click on "Send test" on a webhook row. NoviaMind sends a synthetic webhook.test event, signed exactly like a real delivery, and shows you the HTTP status your endpoint returned — ideal for validating your n8n workflow or signature check before going live.


8.13 Retries, Failures, and Auto-Disable

  • Each event is delivered with up to 5 attempts and exponential backoff.
  • The webhook row shows the last delivery status and the number of consecutive failures.
  • After 20 consecutive failures, the webhook is automatically disabled so a dead URL stops consuming delivery attempts. Fix your endpoint, then click "Re-enable".
  • Any successful delivery resets the failure counter.

Delivery history

Click on "History" to see the last deliveries (up to 50, retained for 30 days): timestamp, event, HTTP status, and attempt number. Only outcome metadata is stored — never the payload itself.


8.14 Managing Webhooks

ActionWho can do it
View webhooks and historyAll team members
Create, edit, test, enable/disableAdmins and regular members
DeleteAdmins only
  • Enable / Disable: pause deliveries without losing the configuration (the switch on each row).
  • Edit: change the URL, description, or subscribed events at any time. The signing secret never changes.
  • Rotate the secret: delete the webhook and create a new one.

💡 Developers can find the full API reference (endpoints, schemas, signature details) in the API documentation linked from the webhooks dialog.