Aller au contenu
Préproduction : l'API répond sur https://api.visiobank.com/v1, avec une clé de test. Les exemples gardent l'adresse de production, api.vizalia.ai, qui ouvrira à la mise en production.

Webhooks

Plutôt que d’interroger l’API, abonnez une URL de votre serveur : Vizalia y envoie un POST à chaque étape d’une session, de l’invitation au rapport.

Un abonnement, c’est une URL HTTPS publique et les événements qu’elle reçoit (* pour tous). Le secret de signature, whsec_…, n’est renvoyé qu’une fois, dans la réponse de la création.

Fenêtre de terminal
curl https://api.vizalia.ai/v1/webhook_endpoints \
-H "Authorization: Bearer $VIZALIA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url": "https://crm.exemple.fr/webhooks/vizalia", "events": ["session.ended", "report.ready"], "description": "Rapport dans le ticket"}'

L’adresse doit être en https, sur le port 443, sans identifiants (https://utilisateur:motdepasse@…), vers un hôte public : une adresse privée (réseau interne, localhost) est refusée avec 422 invalid_webhook_url. Un espace a au plus 16 abonnements par mode, production et test : un abonnement créé avec une clé de test (livemode: false) reçoit les événements des sessions de test. La clé doit avoir la portée webhooks:manage.

Les webhooks suivent la spécification Standard Webhooks : un POST JSON avec les en-têtes webhook-id, webhook-timestamp et webhook-signature. Vérifiez chaque envoi sur le corps brut, avant de le lire, avec la bibliothèque officielle de votre langage (paquet standardwebhooks en Node.js, Python, Ruby, PHP, Go, Java et d’autres) et le secret de l’abonnement :

import { Webhook } from "standardwebhooks";
// rawBody : le corps reçu, octet pour octet ; headers : les en-têtes de la requête
const event = new Webhook(process.env.VIZALIA_WEBHOOK_SECRET).verify(rawBody, headers);
import os
from standardwebhooks import Webhook
event = Webhook(os.environ["VIZALIA_WEBHOOK_SECRET"]).verify(raw_body, headers)

Répondez 2xx en moins de 15 secondes, puis traitez l’événement. webhook-id reste le même d’une tentative à l’autre : gardez-le pour ignorer un doublon. Les envois arrivent au moins une fois, sans ordre garanti ; en cas de doute, relisez la session. GET /events rattrape un envoi manqué.

Le détail de la vérification, avec ou sans bibliothèque, est sur la page Vérifier la signature.

ÉvénementQuanddata
session.createdSession créée
Une session a été créée dans l'espace, quelle que soit son origine (API, console, connecteur).
session
session.invitedInvitation envoyée
Le SMS ou l'e-mail a été accepté par l'opérateur d'envoi.
session
session.invitation_failedInvitation en échec
L'envoi du SMS ou de l'e-mail a échoué. La session reste ouverte et l'invitation peut être renvoyée.
session
session.openedLien ouvert
Le client a ouvert le lien.
session
session.liveVidéo établie
La vidéo du client est établie.
session
photo.createdPhoto prisephoto
video.readyVidéo disponible
Un clip est terminé et son fichier est téléchargeable.
video
document.signedDocument signédocument
measurement.createdMesure prisemeasurement
session.endedSession terminéesession
report.readyRapport disponible
Le rapport de la session est disponible, résumé compris quand la transcription est active. Il suit session.ended, parfois de quelques minutes.
report
case.submittedDossier déposé par un agent vocal
Un agent vocal, le vôtre ou celui de Vizalia, a déposé son dossier pendant l'appel (outil MCP submit_case) : résumé, données, photos et vidéos. Seul événement qui transporte du texte libre. Prend le relais du webhook de dossier réglé aujourd'hui au back-office, et se vérifie comme les autres événements.
case

Chaque envoi a la même forme : type, timestamp, session_id et data, la ressource entière dans son état au moment de l’événement. Le champ object de data dit laquelle. Pour un événement de session, data est la session complète, join_url comprise ; advisor_url n’y est jamais.

session.invited
{
"id": "evt_01JC41QX7M",
"object": "event",
"type": "session.invited",
"timestamp": "2026-11-04T09:12:02Z",
"api_version": "v1",
"livemode": true,
"session_id": "ses_01JC3W8Z5T9QK2N4R6V8X0Y1Z2",
"data": {
"id": "ses_01JC3W8Z5T9QK2N4R6V8X0Y1Z2",
"object": "session",
"legacy_id": 412907,
"status": "invited",
"livemode": true,
"channel": "sms",
"to_masked": "+33 6 •• •• •• 78",
"reopened": false,
"reference": "TICKET-48213",
"metadata": {
"zendesk_ticket_id": "48213"
},
"brand": {
"id": "brd_42",
"name": "Hospidom"
},
"advisor": {
"email": "claire.martin@exemple.fr",
"first_name": "Claire",
"last_name": "Martin"
},
"ai_agent": null,
"locale": "fr",
"join_url": "https://chat.urgentime.com/9f2c4e1a7b3d5e60",
"created_at": "2026-11-04T09:12:01Z",
"invited_at": "2026-11-04T09:12:02Z",
"opened_at": null,
"live_at": null,
"ended_at": null,
"expires_at": "2026-11-04T15:12:01Z",
"duration_seconds": null,
"counts": {
"photos": 0,
"videos": 0,
"documents": 0,
"measurements": 0
},
"report": {
"status": "pending",
"url": "https://api.vizalia.ai/v1/sessions/ses_01JC3W8Z5T9QK2N4R6V8X0Y1Z2/report",
"pdf_url": "https://api.vizalia.ai/v1/sessions/ses_01JC3W8Z5T9QK2N4R6V8X0Y1Z2/report?format=pdf"
}
}
}

timestamp est le moment où l’événement s’est produit : il peut précéder l’envoi. Le seul événement qui transporte du texte libre est case.submitted, le dossier déposé par un agent vocal. Aucune route ne relit un dossier : gardez-le à sa réception ; le journal, GET /events, le garde 30 jours.

Tout code 2xx reçu en moins de 15 secondes vaut accusé de réception. Sinon, nouvelles tentatives après 1 min, 5 min, 30 min, 2 h, 6 h, 12 h et 24 h, avec un léger aléa.

Un abonnement en échec depuis 3 jours est coupé par Vizalia : il passe en status: disabled, avec disabled_reason: failing_deliveries. Corrigez votre serveur, puis réactivez-le :

Fenêtre de terminal
curl -X PATCH https://api.vizalia.ai/v1/webhook_endpoints/we_01JC40AB12 \
-H "Authorization: Bearer $VIZALIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "enabled"}'

Le champ error du résultat d’un envoi, par exemple celui de POST /webhook_endpoints/{endpoint_id}/test, prend l’un de ces codes stables, nul si l’envoi a réussi :

errorCause
http_<statut>Votre serveur a répondu hors 2xx, par exemple http_500.
timeoutPas de réponse dans les 15 secondes.
tls_errorLa connexion TLS a échoué, par exemple sur un certificat invalide.
dns_errorLe nom d'hôte ne se résout pas.
connection_errorLa connexion a été refusée ou interrompue.
unreachableL'hôte est injoignable.
address_not_allowedL'adresse résout vers un réseau privé ou réservé : Vizalia n'y envoie rien.
secret_unavailableLe secret de signature n'a pas pu être lu côté Vizalia ; écrivez au support s'il persiste.

GET /events garde les événements des 30 derniers jours, tels qu’ils ont été envoyés, les plus récents d’abord. Il sert aussi à suivre une session sans webhook. Filtrez par type, session_id ou created_after.

Fenêtre de terminal
curl "https://api.vizalia.ai/v1/events?type=report.ready&created_after=2026-11-04T00:00:00Z" \
-H "Authorization: Bearer $VIZALIA_API_KEY"

created_after est inclusif : un événement daté de cette heure exacte est renvoyé. Pour rattraper régulièrement, relisez le journal depuis le dernier événement traité, avec une petite marge, et écartez les événements déjà vus par leur id, le même que celui du webhook :

rattrapage.mjs
// Rattrapage par le journal (Node.js 18 ou plus) : relit les événements depuis
// le dernier traité, au cas où un webhook se serait perdu. Le journal garde
// 30 jours d'événements, les plus récents d'abord ; created_after est inclusif.
const API = process.env.VIZALIA_API_BASE ?? 'https://api.vizalia.ai/v1';
const CLE = process.env.VIZALIA_API_KEY;
export async function rattraper(depuis, traiter, dejaVu) {
const evenements = [];
let apres;
do {
const parametres = new URLSearchParams({ created_after: depuis, limit: '100', ...(apres ? { starting_after: apres } : {}) });
const reponse = await fetch(`${API}/events?${parametres}`, { headers: { Authorization: `Bearer ${CLE}` } });
if (!reponse.ok) throw new Error(`GET /events : ${reponse.status}`);
const page = await reponse.json();
evenements.push(...page.data);
apres = page.has_more ? page.data.at(-1).id : undefined;
} while (apres);
// Du plus ancien au plus récent, et chaque événement une seule fois : le même
// id arrive par webhook et par le journal.
for (const evenement of evenements.reverse()) {
if (await dejaVu(evenement.id)) continue;
await traiter(evenement);
}
return evenements.at(-1)?.timestamp ?? depuis;
}
// Par exemple toutes les 5 minutes, depuis le dernier horodatage traité moins une marge :
// dernier = await rattraper(new Date(Date.parse(dernier) - 120_000).toISOString(), traiter, dejaVu);

Aucune route de l’API ne liste les envois d’un abonnement ni ne rejoue un envoi : le journal est le moyen de rattraper. Dans le back-office, l’onglet Webhooks montre les livraisons de chaque abonnement et rejoue un envoi.

Un abonnement exige une adresse https publique : localhost est refusé. Sur un poste de développeur, exposez votre récepteur par un tunnel HTTPS (cloudflared, ngrok…), puis abonnez son adresse avec une clé de test : l’abonnement reçoit les événements des sessions de test, que simulate fait avancer sans téléphone. Sans tunnel, suivez les sessions par le journal, GET /events.

POST /webhook_endpoints/{endpoint_id}/rotate_secret crée un nouveau secret, renvoyé une seule fois. Pendant 24 heures, chaque envoi porte deux signatures, l’une avec l’ancien secret et l’autre avec le nouveau : déployez le nouveau secret pendant ce délai, la vérification passe avec l’un comme avec l’autre.