Ir al contenido principal

Webhooks de Ovianta: guía para desarrolladores

Cómo recibir en tu servidor los eventos de Ovianta (fin de llamada y nota creada): configuración, verificación de la firma, reintentos y referencia completa de los datos.

Escrito por Adán

Introducción

Ovianta envía una petición HTTPS POST firmada a la URL que configures cada vez que ocurre un evento en tu cuenta. Así tu sistema (un CRM, una cola de tareas, tu propio backend) se entera al momento sin tener que consultarnos.

Hay dos eventos, más uno de prueba:

Evento

Cuándo se envía

Para qué sirve

call.ended

Una vez por conversación de un agente, por teléfono o desde el widget web, entrante o saliente, contestada o no.

Archivar la conversación y conciliar lo que el agente hizo en tu agenda.

note.created

Cada vez que se deja una nota para el equipo de la clínica, la escriba quien la escriba.

Avisar a recepción de que hay trabajo pendiente.

webhook.test

Solo cuando pulsas "Enviar evento de prueba" en Ajustes.

Comprobar que tu endpoint recibe y verifica bien.

Los webhooks son una notificación de un solo sentido: lo que respondas no cambia nada en Ovianta, solo confirma que lo recibiste.

Configuración

Un administrador de tu organización da de alta los endpoints en Ajustes → Webhooks de Ovianta.

  1. Pulsa Añadir endpoint.

  2. Escribe la URL de tu servidor y, si quieres, una descripción para reconocerlo.

  3. Marca los eventos que quieres recibir. Un endpoint nuevo viene con todos marcados.

  4. Abre el endpoint y copia la clave de firma (whsec_…) con Revelar. La necesitas para verificar cada petición.

  5. Pulsa Enviar evento de prueba para comprobar que tu servidor responde.

Requisitos de la URL:

  • Tiene que empezar por https://.

  • Tiene que apuntar a un servidor público. Se rechazan localhost, las IP privadas y las direcciones internas, también si un dominio público resuelve a una de ellas.

  • No puede llevar usuario ni contraseña.

  • No seguimos redirecciones: una respuesta 3xx cuenta como rechazo.

Puedes tener hasta 10 endpoints por organización, cada uno con su clave y sus eventos. Si añadimos un tipo de evento nuevo, no se activa solo en tus endpoints: tendrás que marcarlo.

En el panel de cada endpoint ves sus peticiones de los últimos 30 días, con su estado, el número de intentos y el último código HTTP que devolvió tu servidor.

La petición

Cada evento llega como un POST con cuerpo JSON en UTF-8.

POST https://tu-servidor.com/webhooks/ovianta
Content-Type: application/json; charset=utf-8
Accept: application/json
User-Agent: Ovianta-Webhooks/1.0
X-Signature: 4f3c9a1e8b7d2c05e6a1b9d84c73f210a5e8b6d4c92f10e73a8b45d6c1f20e93
X-Ovianta-Event: call.ended
X-Ovianta-Event-Id: evt_9f2c1a7b4e5d40c8a1b2c3d4
X-Ovianta-Delivery-Attempt: 1

Cabecera

Qué lleva

X-Signature

Firma HMAC-SHA256 del cuerpo, en hexadecimal en minúsculas. Ver Verificar la firma.

X-Ovianta-Event

Tipo de evento. Sirve para enrutar antes de leer el JSON.

X-Ovianta-Event-Id

Id del evento, el mismo que eventId en el cuerpo. Sirve para descartar duplicados.

X-Ovianta-Delivery-Attempt

Número de intento, de 1 a 5.

X-Ovianta-Test

true, solo en el evento de prueba.

Las cabeceras no van firmadas: si alguna no coincide con el cuerpo, manda el cuerpo.

Todos los eventos comparten el mismo sobre. Lo propio de cada tipo va dentro de data:

{
"event": "call.ended",
"eventId": "evt_9f2c1a7b4e5d40c8a1b2c3d4",
"version": 1,
"sentAt": "2026-10-06T13:24:43.253+02:00",
"attempt": 1,
"data": { }
}

Campo

Tipo

Descripción

event

texto

Tipo de evento.

eventId

texto

Id del evento. Es igual en todos los reintentos y en todos tus endpoints que lo reciben.

version

número

Versión del contrato de ese tipo de evento.

sentAt

fecha y hora

Cuándo salió este intento.

attempt

número

Número de intento, de 1 a 5.

data

objeto

El contenido del evento.

Todas las fechas van en ISO 8601 con desfase horario, en hora de España peninsular (por ejemplo 2026-10-06T13:24:43+02:00).

Verificar la firma

Rechaza cualquier petición cuya firma no coincida. El cuerpo contiene datos de pacientes y la firma es lo que prueba que viene de Ovianta y que nadie lo ha modificado.

X-Signature es el HMAC-SHA256 del cuerpo tal cual llega, con la clave de firma del endpoint (la cadena completa whsec_…, prefijo incluido) como clave, en hexadecimal en minúsculas. Para verificarlo:

  1. Lee el cuerpo en bruto, antes de convertirlo a JSON. Si lo parseas y lo vuelves a serializar, los bytes cambian y la firma no cuadra.

  2. Calcula el HMAC-SHA256 de esos bytes con tu clave.

  3. Compáralo con X-Signature usando una comparación de tiempo constante.

  4. Opcional, contra reenvíos: sentAt va dentro del cuerpo firmado, así que puedes rechazar los eventos con más de unos minutos de antigüedad.

Si crees que tu clave se ha filtrado, borra el endpoint y crea otro: tendrá una clave nueva.

Entrega y reintentos

Responde con un 2xx en menos de 10 segundos y procesa el evento después, en segundo plano. Un endpoint lento acaba en un reintento, y un reintento en un duplicado que tendrás que filtrar.

Tu respuesta

Qué hacemos

2xx

Entregado. No se reintenta. El cuerpo de tu respuesta se ignora.

408, 429, 5xx, sin respuesta en 10 s, error de conexión o de TLS

Fallo temporal: se reintenta según el calendario.

Cualquier otro 4xx, o una redirección 3xx

Rechazo definitivo: no se reintenta.

Calendario de reintentos:

Intento

Espera desde el anterior

Tiempo desde el evento (aprox.)

1

—

inmediato

2

1 min

1 min

3

5 min

6 min

4

15 min

21 min

5

40 min

1 h

Si respondes con una cabecera Retry-After, la respetamos cuando pide esperar más que el calendario, hasta un máximo de 1 hora. Tras el quinto fallo dejamos de intentarlo y la entrega queda como fallida en el panel del endpoint.

Antes de cada intento volvemos a leer tu configuración: si has borrado o deshabilitado el endpoint, o ya no está suscrito a ese evento, la entrega se cancela.

Duplicados y orden:

  • La entrega es "al menos una vez". Descarta duplicados por eventId (o X-Ovianta-Event-Id), que es el mismo en todos los reintentos.

  • No garantizamos el orden. Dos eventos casi simultáneos pueden llegar en cualquier orden. Si te importa la secuencia, ordena por data.call.endedAt o data.note.createdAt.

  • Tamaño. Un call.ended corto ocupa unos 2 KB; una conversación larga, varias decenas de KB. No limites el cuerpo por debajo de 256 KB.

Evento call.ended

Se envía una vez por cada conversación que termina un agente: entrante o saliente, por teléfono o desde el widget web, contestada o no. WhatsApp no genera call.ended.

Llega cuando Ovianta ha terminado de procesar la llamada, normalmente entre unos segundos y un minuto después de colgar. Si la llamada se transfirió a una persona, esperamos a saber cómo acabó esa transferencia, hasta 10 minutos.

data

Campo

Tipo

Descripción

agent

objeto o null

El agente: id, name (puede ser null) y type.

center

objeto o null

El centro de la llamada: id, name y externalId (tu id del centro, solo si está vinculado en la integración). null si no se puede saber.

call

objeto

La conversación. Campos abajo.

data.call

Campo

Tipo

Descripción

id

texto

Id de la llamada en Ovianta. Cítalo en cualquier consulta a soporte.

direction

enum

inbound o outbound.

channel

enum

phone o widget.

modality

enum

voice o text.

startedAt

fecha y hora

Inicio de la conversación.

endedAt

fecha y hora

Fin de la conversación con el agente. En una transferencia, cuando el agente pasó la llamada, no cuando acabó la conversación con la persona.

durationSecs

número

Duración en segundos.

outcome

enum

Si hubo conversación. Ver Valores posibles.

endedBy

enum o null

Quién o qué la terminó. null si no llegó a conectar.

phone

objeto

from, to y patient, en formato E.164. Los tres son null en el widget.

patient

objeto o null

El paciente, si lo hay.

transfer

objeto o null

La transferencia a una persona. null si no la hubo.

actions

lista

Lo que el agente cambió en tu sistema. Puede estar vacía.

transcript

lista

La conversación por turnos. Vacía si no se contestó.

data.call.phone

Campo

Descripción

from

Número que llama: el paciente en una entrante, la línea del agente en una saliente.

to

Número al que se llama: la línea del agente en una entrante, el paciente en una saliente.

patient

El teléfono real del paciente. Si la llamada llegó desde una centralita o un intermediario, es null en lugar de un número al que no sirve llamar.

data.call.patient

Campo

Descripción

id

Id del paciente en Ovianta, o null.

externalId

Id del paciente en tu sistema, si el agente lo buscó o lo dio de alta durante la llamada.

name

Nombre del paciente en Ovianta, o null.

data.call.transfer

Campo

Descripción

requestedBy

Siempre agent por ahora.

reason

Clave de la regla de transferencia que se aplicó, por ejemplo consultas_seguros. Puede no venir.

destination

Número al que se transfirió, en E.164.

summary

Resumen que el agente le pasó a quien atendió, o null.

outcome

Cómo acabó la transferencia. Ver Valores posibles.

connected

Si se llegó a conectar al paciente con el destino, o null.

durationSecs

Duración de la parte con la persona, sin contar el tono, o null.

endedAt

Cuándo acabó la parte con la persona, o null.

data.call.actions[] — solo operaciones que escriben (las búsquedas y consultas de disponibilidad no aparecen), en el orden de la conversación.

Campo

Descripción

type

La operación. Ver Valores posibles.

status

executed o failed. Una acción fallida se intentó y tu sistema la rechazó: no cambió nada.

appointmentId

Id de la cita en tu sistema, si lo hay.

patientId

Id del paciente en tu sistema, si lo hay.

appointment

Qué se reservó o movió: startsAt (en un cambio, la nueva hora), doctorId, serviceId y centerId (tus ids). Solo con la integración de agenda por webhook.

note

En un create_note ejecutado, la nota completa: el mismo objeto note que llega en note.created, con el mismo id (ver Evento note.created). No viene si la acción falló.

error

Si falló: type (ver Valores posibles), code (tu código de error, si lo devolviste) y message, un texto fijo por categoría.

data.call.transcript[]

Campo

Descripción

role

agent o patient.

at

Segundos desde el inicio de la llamada.

text

Lo que se dijo, transcrito. Puede no venir en un turno que solo usó herramientas.

toolCalls

Herramientas que el agente usó en ese turno, en orden: name y status (success, error, o unknown si no hubo un resultado reconocible: la llamada se cortó o la respuesta no indicaba el resultado). No llevan los datos enviados ni la respuesta del sistema: lo que el agente cambió va en actions, y cada nota, dentro de su acción create_note. Las herramientas internas de Ovianta no aparecen.

La traza de toolCalls sirve para diagnosticar y puede cambiar sin aviso. Para conciliar con tu agenda, usa actions.

Evento note.created

Se envía cada vez que se deja una nota para el equipo de la clínica, la cree quien la cree: el agente durante una llamada o un chat de WhatsApp, el análisis posterior de la conversación o una regla automática (por ejemplo, una transferencia que nadie atendió). Llega unos segundos después de crearse la nota.

Es independiente de call.ended: la nota trae lo necesario para actuar (a quién llamar, por qué y los campos que tu clínica pide en cada nota) sin cruzarla con nada más. Si además quieres archivarla con su llamada, note.callId es el data.call.id de su call.ended, y la misma nota, con el mismo note.id, llega también dentro de la acción create_note de ese evento.

data

Campo

Tipo

Descripción

note.id

texto

Id de la nota en Ovianta.

note.createdAt

fecha y hora

Cuándo se creó.

note.callId

texto o null

Id de la llamada en Ovianta de la que salió la nota, el mismo que data.call.id en su call.ended. null si la nota no viene de una llamada (WhatsApp) o no se puede saber.

note.callReason

texto o null

El motivo: lo que hay que hacer.

note.patientName

texto o null

Nombre del paciente, tal como se capturó.

note.phone

texto o null

Teléfono de contacto, en E.164.

note.fields

lista

Los campos que el agente rellena en cada nota (los que tu clínica configuró: aseguradora, ciudad, tipo de paciente…). Ver data.note.fields[] abajo.

channel

enum

voice (llamada o widget web) o whatsapp.

agent

objeto o null

El agente: id, name y type.

center

objeto o null

El centro del agente: id, name y externalId. null si el agente atiende varios centros, porque la nota no indica de cuál es.

data.note.fields[] — siempre todos los campos del agente, en el orden en que están configurados, también los que esta nota dejó vacíos: así cada nota trae la misma lista.

Campo

Descripción

key

Id estable del campo. Úsalo para mapearlo en tu sistema.

label

Nombre del campo tal como lo ve la clínica. Puede cambiar si lo renombran: no mapees por él.

value

Lo que se capturó: texto, número o booleano. null si la nota no lo rellenó.

En este evento solo se notifica la creación: los cambios de estado posteriores de la nota no generan eventos.

Evento webhook.test

Se envía solo al pulsar Enviar evento de prueba en el panel del endpoint. Va firmado con la clave real, lleva la cabecera X-Ovianta-Test: true, se envía una sola vez sin reintentos, y el código que devuelva tu servidor aparece en pantalla.

"data": {
"message": "Test event from Ovianta. Respond with a 2xx status to confirm the endpoint works."
}

Responde con un 2xx aunque tu servidor no procese este tipo de evento: así la prueba confirma que la URL y la verificación de firma funcionan.

Valores posibles

Pueden aparecer valores nuevos sin cambio de versión. Si recibes uno que no conoces, trátalo como el caso neutro más cercano en lugar de como un error.

call.outcome

Valor

Significado

completed

Hubo conversación y llegó a su fin.

no_answer

Llamada saliente no contestada o comunicando. La transcripción va vacía.

voicemail

Contestó un buzón de voz.

failed

La llamada no se pudo establecer o se cortó por un error técnico.

call.endedBy

Valor

Significado

patient

Colgó el paciente o cerró el widget.

agent

El agente cerró la conversación.

transfer

El agente pasó la llamada a una persona. transfer viene relleno.

timeout

Se cortó por silencio prolongado o por superar la duración máxima.

error

Se cortó por un fallo técnico.

null

No aplica: la llamada no llegó a conectar.

transfer.outcome

Valor

Significado

answered

El destino descolgó y se conectó con el paciente. Puede haber sido un buzón de voz del destino.

busy

El destino comunicaba.

no_answer

El destino no contestó.

failed

La transferencia no se completó, incluido el rechazo por parte del destino.

unknown

Se hizo la transferencia pero no supimos cómo acabó. No es un fallo.

actions[].type

Valor

Significado

create_appointment

Se reservó una cita nueva.

reschedule_appointment

Se movió una cita existente. appointment.startsAt es la nueva hora.

cancel_appointment

Se canceló una cita.

confirm_appointment

El paciente confirmó su asistencia.

modify_appointment

Se cambiaron datos de una cita existente (tipo, estado, duración…).

create_patient

Se dio de alta un paciente en tu sistema.

create_note

Se dejó una nota para la clínica. La nota completa va en note.

transfer_to_human

Se pasó la llamada a una persona. Los detalles van en call.transfer.

error.type

Valor

Significado

business_rule

Tu sistema lo rechazó por una regla de negocio: hueco ocupado, cita ya cancelada, paciente no admitido. Esperado.

validation

Faltaba un dato obligatorio o la petición no era válida.

integration

Tu sistema devolvió un error o no respondió. Conviene revisarlo.

unknown

Fallo sin clasificar.

Ejemplos

Datos ficticios. La transcripción del primero está recortada para que se lea mejor; en un evento real van todos los turnos.

call.ended: llamada entrante, cita movida, una cancelación rechazada y transferencia a recepción

{
"event": "call.ended",
"eventId": "evt_9f2c1a7b4e5d40c8a1b2c3d4",
"version": 1,
"sentAt": "2026-09-01T11:41:12.482+02:00",
"attempt": 1,
"data": {
"agent": { "id": "7c1f04d8-3b62-4a19-8f30-9d1e2c7a5b44", "name": "Recepción Sant Jordi", "type": "appointments_booking" },
"center": { "id": "b81e5a2c-7f14-4d80-9c66-3ab0e1d95f27", "name": "Sant Jordi Eixample", "externalId": "CEN-02" },
"call": {
"id": "3ab6f0d2-59c4-4f0e-9a71-2b8d5c6e1f10",
"direction": "inbound",
"channel": "phone",
"modality": "voice",
"startedAt": "2026-09-01T11:36:05.000+02:00",
"endedAt": "2026-09-01T11:39:41.000+02:00",
"durationSecs": 216,
"outcome": "completed",
"endedBy": "transfer",
"phone": { "from": "+34600112233", "to": "+34932556677", "patient": "+34600112233" },
"patient": { "id": "5d21b7e0-8c43-4f52-b1a9-6e07d4c81b33", "externalId": "P-99120", "name": "Marta Ruiz" },
"transfer": {
"requestedBy": "agent",
"reason": "consultas_seguros",
"destination": "+34932556680",
"summary": "Marta Ruiz pregunta si su seguro cubre la prueba de esfuerzo.",
"outcome": "answered",
"connected": true,
"durationSecs": 84,
"endedAt": "2026-09-01T11:41:05.000+02:00"
},
"actions": [
{
"type": "reschedule_appointment",
"status": "executed",
"appointmentId": "A-448122",
"patientId": "P-99120",
"appointment": { "startsAt": "2026-09-08T10:30:00+02:00", "doctorId": "DR-14", "serviceId": "SRV-3", "centerId": "CEN-02" }
},
{
"type": "cancel_appointment",
"status": "failed",
"appointmentId": "A-441097",
"error": { "type": "business_rule", "code": "APPOINTMENT_PREVIOUSLY_CANCELLED", "message": "The clinic's system rejected the operation." }
},
{
"type": "create_note",
"status": "executed",
"patientId": "P-99120",
"note": {
"id": "f4d83dde-f150-4891-b8cb-d42928e9cfbe",
"createdAt": "2026-09-01T11:38:57.785+02:00",
"callId": "3ab6f0d2-59c4-4f0e-9a71-2b8d5c6e1f10",
"patientName": "Marta Ruiz",
"phone": "+34600112233",
"callReason": "Pregunta si el seguro cubre la prueba de esfuerzo. Prefiere que la llamen por la tarde.",
"fields": [
{ "key": "customField1", "label": "Aseguradora", "value": "Sanitas" },
{ "key": "customField2", "label": "Franja preferida", "value": "Tarde" },
{ "key": "customField3", "label": "Nº de póliza", "value": null }
]
}
},
{ "type": "transfer_to_human", "status": "executed" }
],
"transcript": [
{ "role": "agent", "at": 0, "text": "Clínica Sant Jordi, le atiende el asistente virtual. ¿En qué puedo ayudarle?" },
{ "role": "patient", "at": 5, "text": "Quería cambiar la cita del jueves con el doctor Vidal." },
{
"role": "agent",
"at": 52,
"toolCalls": [
{
"name": "reschedule_appointment",
"status": "success"
}
]
},
{ "role": "agent", "at": 58, "text": "Su cita queda cambiada al martes 8 a las 10:30." }
]
}
}
}

call.ended: llamada saliente que nadie contestó

{
"event": "call.ended",
"eventId": "evt_c40b81de73f24a90b1c2d3e4",
"version": 1,
"sentAt": "2026-09-01T17:22:09.771+02:00",
"attempt": 1,
"data": {
"agent": { "id": "e2905ba7-1cd4-49f8-b0a2-7f4c33e1d8a6", "name": "Recordatorios Sant Jordi", "type": "appointments_reminder" },
"center": { "id": "4c7a91be-2d05-4e37-88ab-1f60c9d4e2b8", "name": "Sant Jordi Gràcia", "externalId": "CEN-05" },
"call": {
"id": "f18e2a67-90b3-4c11-8d75-6ea3b0c94d52",
"direction": "outbound",
"channel": "phone",
"modality": "voice",
"startedAt": "2026-09-01T17:21:40.000+02:00",
"endedAt": "2026-09-01T17:21:40.000+02:00",
"durationSecs": 0,
"outcome": "no_answer",
"endedBy": null,
"phone": { "from": "+34932556677", "to": "+34611223344", "patient": "+34611223344" },
"patient": null,
"transfer": null,
"actions": [],
"transcript": []
}
}
}

note.created

{
"event": "note.created",
"eventId": "evt_c908551da2eadcc8599912ea",
"version": 1,
"sentAt": "2026-09-01T11:39:00.380+02:00",
"attempt": 1,
"data": {
"note": {
"id": "f4d83dde-f150-4891-b8cb-d42928e9cfbe",
"createdAt": "2026-09-01T11:38:57.785+02:00",
"callId": "3ab6f0d2-59c4-4f0e-9a71-2b8d5c6e1f10",
"patientName": "Marta Ruiz",
"phone": "+34600112233",
"callReason": "Pregunta si el seguro cubre la prueba de esfuerzo. Prefiere que la llamen por la tarde.",
"fields": [
{ "key": "customField1", "label": "Aseguradora", "value": "Sanitas" },
{ "key": "customField2", "label": "Franja preferida", "value": "Tarde" },
{ "key": "customField3", "label": "Nº de póliza", "value": null }
]
},
"channel": "voice",
"agent": { "id": "7c1f04d8-3b62-4a19-8f30-9d1e2c7a5b44", "name": "Recepción Sant Jordi", "type": "appointments_booking" },
"center": { "id": "b81e5a2c-7f14-4d80-9c66-3ab0e1d95f27", "name": "Sant Jordi Eixample", "externalId": "CEN-02" }
}
}
¿Ha quedado contestada tu pregunta?