Guía de integración de Omnimed AI
Integrá el dictado de informes de Omnimed AI en tu RIS, HIS o visor.
1. Widget
Agregá un tag <script> v2 a tu página y pasale getToken: tu backend cambia la Partner Credential por un token de 1 h por médico. El informe puede autoinsertarse en el editor activo.
Usá esta opción si: querés la integración más rápida y te sirve la interfaz de grabación por defecto.
2. API directa
Enviá un archivo de audio por POST a /api/omni-ai desde tu servidor, con tu Partner Credential como Bearer, y manejá la respuesta vos mismo.
Usá esta opción si: necesitás control total de la interfaz de grabación o integrás desde un cliente nativo o de escritorio.
Inicio rápido
URL base: https://api.omniai.com.ar
-
Pedí tu Partner Credential (credencial de partner) a [email protected]. Es un secreto
omp_...que se muestra una sola vez: guardalo como variable de entorno de tu servidor. Ver Alta de un partner. -
Hacé la primera llamada. Desde tu servidor, con un audio de prueba:
curl -X POST https://api.omniai.com.ar/api/omni-ai \ -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \ -F "[email protected]" \ -F "medical_center_id=YOUR_CENTER_ID" \ -F "username=dr.perez"Si vas a usar el Widget en el navegador, tu backend cambia la credencial por un token por médico y la página lo recibe con
OmniWidget.init({ getToken })(ver Widget v2). -
Leé la respuesta. El informe viene en
data.reports[0].report_html; guardádata.metadata.study_idsi vas a refinarlo después (ver Respuesta exitosa).
Autenticación
Cada partner de integración recibe de Omnimed AI una Partner Credential (omp_...), habilitada para uno o más centros. Cada centro tiene su medical_center_id, que identifica sus plantillas, su idioma y su estilo de informe.
Partner Credential (servidor a servidor)
Cuando tu servidor llama a la API directamente, manda la credencial como Bearer en cada llamada y nombra el centro y el médico en el formulario:
Authorization: Bearer omp_...: tu Partner Credential. Solo en tu servidor (variable de entorno, gestor de secretos); nunca en el navegador.medical_center_id: un centro para el que tu credencial está habilitada.username: el médico declarado (ver abajo).
/api/omni-ai/refine y /api/omni-ai/addendum llevan el mismo header y el mismo medical_center_id.
Un header Authorization: Bearer cuyo valor no empieza con omp_ ni con omw_ se ignora: la llamada se trata como si no trajera credencial.
Token del Widget
El Widget corre en el navegador del médico, así que nunca recibe la Partner Credential. Tu backend la cambia por un token del Widget (omw_...) con POST /api/widget/token, para un centro y un médico, y se lo pasa a la página. El token dura una hora y sirve para un solo médico de un solo centro. El Widget lo manda como Authorization: Bearer omw_... en cada llamada y, cuando vence, le pide uno nuevo a tu página con getToken().
Con un token del Widget, el centro y el médico salen del token: si la llamada además manda medical_center_id o username, se ignoran.
Un token no se revoca por separado: vence a la hora. Lo que se revoca o se rota es la Partner Credential, y los tokens que emitió dejan de valer en menos de 60 segundos.
Médico declarado (username)
username es el usuario del médico en el centro (el mismo login que usa en la app de escritorio). Lo declara tu sistema con su credencial: nosotros no autenticamos a ese médico, confiamos en tu palabra dentro de los centros para los que estás habilitado. Decide entre qué plantillas se elige la del dictado:
username | Plantillas entre las que se elige |
|---|---|
| ausente (solo API directa) | Solo las plantillas generales del centro |
| un usuario del centro | Las generales más las asignadas a ese médico (directamente o por grupo) |
| un usuario que el centro no conoce | Solo las generales; la llamada queda registrada con el nombre declarado |
Nunca se crea un usuario a partir de un username. Decidilo en tu backend a partir de tu propia sesión, nunca a partir de algo que mande el navegador: así un médico no puede actuar a nombre de otro.
Widget v2
1. Tu backend pide el token
POST https://api.omniai.com.ar/api/widget/token
Authorization: Bearer omp_...
Content-Type: application/json| Dónde | Campo | Requerido | Descripción |
|---|---|---|---|
| Header | Authorization: Bearer omp_... | Sí | Tu Partner Credential. Solo en tu servidor |
| Body JSON | medical_center_id | Sí | El centro para el que actúa el Widget (el mismo ID que en v1). Tiene que estar habilitado para tu credencial |
| Body JSON | username | Sí | El médico que usa el Widget, con su usuario en el centro (médico declarado). Un token es por médico |
| Body JSON | user_id | No | Tu propio ID del médico; solo se registra |
Respuesta 200:
{
"token": "omw_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-10-14T18:32:10Z",
"expires_in": 3600
}token: el token del Widget, para pasarle a la página.expires_at: vencimiento en ISO 8601 (UTC). Podés cachear el token por médico hasta ese momento en lugar de pedir uno en cada carga de página.expires_in: segundos hasta el vencimiento.
cURL
curl -X POST https://api.omniai.com.ar/api/widget/token \
-H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{"medical_center_id": "YOUR_CENTER_ID", "username": "dr.perez"}'Node (Express)
Un endpoint de tu backend que devuelve el token como texto plano para el médico con sesión iniciada. Usa fetch nativo (Node 18+).
// OMNI_PARTNER_CREDENTIAL=omp_... (variable de entorno, nunca en el front)
app.get('/omni-token', requireLogin, async (req, res) => {
const r = await fetch('https://api.omniai.com.ar/api/widget/token', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OMNI_PARTNER_CREDENTIAL}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
medical_center_id: req.user.omniCenterId, // p. ej. 'YOUR_CENTER_ID'
username: req.user.username, // el médico de esta sesión
user_id: String(req.user.id), // opcional
}),
});
if (!r.ok) {
// 401: credencial inválida o revocada; 403: centro no habilitado (ver Errores)
return res.status(502).send(await r.text());
}
const { token } = await r.json();
res.type('text/plain').send(token);
});El Widget llama a getToken() recién antes de su primera subida, no al cargar la página: un endpoint de token mal configurado recién se nota cuando el médico termina de dictar. Probalo desde tu backend (por ejemplo con el cURL de arriba) antes de salir a producción.
2. Inicializar el Widget en la página
<script src="https://api.omniai.com.ar/static/widget/v2/omni-widget.min.js"></script>
<script>
OmniWidget.init({
// Lo llama el Widget antes de la primera subida y cada vez que el token vence.
getToken: () =>
fetch('/omni-token', { credentials: 'include' }).then((r) => {
if (!r.ok) throw new Error('No se pudo obtener el token de Omnimed AI');
return r.text();
}),
onReport: ({ html, text, recordingId }) => {
// Insertar el informe en tu editor (ver "Autoinsertar el informe")
},
});
</script>Se inyecta un botón de grabación flotante abajo a la derecha. El médico lo toca, dicta, frena, y el Widget muestra el informe generado.
Sin data-center-id en el <script>. Si el tag conserva data-center-id (de una integración v1), el Widget arranca solo en modo v1 al cargar, y un OmniWidget.init({ getToken }) posterior se ignora con el aviso "Already initialized" en la consola. En v2 el centro y el médico salen del token; si pasás centerId o username en el init, se ignoran.
- Si ya tenés un token al renderizar la página, podés pasarlo también:
OmniWidget.init({ token: 'omw_...', getToken }). El Widget usa ese primero y llama agetToken()recién cuando vence. - Con
tokensolo, singetToken, el Widget funciona una hora y después muestra un error. Pasá siempregetToken. - Para un médico distinto (cambio de usuario en tu producto) llamá
OmniWidget.destroy()y volvé ainitcon ungetTokenque traiga el token del nuevo médico.
Opción de init | Descripción |
|---|---|
| getToken | Función que devuelve (o promete) el token omw_... como texto. Recomendada siempre |
| token | Token inicial, opcional |
| apiUrl | URL base de la API. Por defecto, el origen desde el que se cargó el script (https://api.omniai.com.ar) |
| onReport | Callback con el informe, igual que el evento omniai:report |
| onAudio | Callback con la grabación cruda, igual que el evento omniai:audio |
| onAddendum | Callback con la adenda, igual que el evento omniai:addendum |
| addendumButton | false oculta el botón Adenda del Widget (por defecto se muestra) |
Autoinsertar el informe
El Widget emite omniai:report (y llama a onReport) cuando el informe está listo, cada vez que el médico toca Insertar y después de cada refinamiento. event.detail trae html, text y el recordingId del dictado. Como el mismo dictado puede llegar varias veces, tu código tiene que reemplazar lo que ya insertó para ese recordingId en lugar de agregar otra copia:
// Un contenedor por dictado: el evento siguiente del mismo recordingId lo reemplaza.
window.addEventListener('omniai:report', (event) => {
const { html, recordingId } = event.detail;
const editor = document.querySelector('#informe');
let bloque = editor.querySelector(`[data-omni-recording="${recordingId}"]`);
if (!bloque) {
bloque = document.createElement('div');
bloque.dataset.omniRecording = recordingId;
editor.appendChild(bloque);
}
bloque.innerHTML = html; // HTML del informe, sanitizado del lado del servidor
});Guardar una copia del audio
Si tu sistema tiene que conservar el dictado original (p. ej. retención legal en el PACS), el Widget te entrega la grabación cruda en el momento en que el médico frena, antes de subirla, así tu copia existe aunque el procesamiento falle. Escuchá omniai:audio o pasá onAudio a init:
window.addEventListener('omniai:audio', (event) => {
const { blob, mimeType, kind, recordingId, parentRecordingId } = event.detail;
// p. ej. subir el blob a tu storage, con tu ID de estudio + recordingId
});| Campo | Descripción |
|---|---|
| blob | La grabación (Blob), normalmente WebM/Opus (Ogg o MP4 en navegadores sin WebM) |
| mimeType | Tipo MIME del blob |
| durationSeconds | Duración de la grabación |
| sizeBytes | Tamaño del blob |
| kind | "dictation", "refinement" (una corrección por voz) o "addendum" |
| recordingId | ID único de esta grabación. Los payloads de omniai:report y omniai:addendum traen el mismo recordingId, que vincula audio y resultado |
| parentRecordingId | En un refinamiento, el recordingId del dictado que corrige; null en los demás |
| recordedAt | Timestamp ISO 8601 |
El blob no sale de tu página por este evento: guardarlo depende de tu código.
Adenda sobre un informe ya firmado
Cuando el médico ya firmó el informe en tu sistema y necesita agregarle algo (un hallazgo que faltó, o la comparación con estudios previos que trajo el paciente), abre una adenda en vez de dictar un informe nuevo. El Widget graba y la API devuelve solo lo dictado, en párrafos, sin título y en el idioma de informes del centro; vos lo agregás al informe con tu propio encabezado, fecha y firma. El informe firmado nunca se modifica. No se elige plantilla, no hay "Refinar" y las adendas no consumen créditos.
<button id="agregar-adenda">Agregar adenda</button>
<script>
// 1. Tu botón abre la adenda y el Widget empieza a grabar enseguida.
document.getElementById('agregar-adenda').addEventListener('click', () => {
OmniWidget.openAddendum({
signedReport: miVisor.informeFirmadoHtml(), // opcional: HTML o texto, tal como lo tenés
externalStudyId: miVisor.accessionNumber(), // opcional: tu ID del estudio
});
});
// 2. Cuando el médico toca "Insertar", llega la adenda para que la agregues.
window.addEventListener('omniai:addendum', (e) => {
const { html, text, externalStudyId, transcription } = e.detail;
miVisor.agregarAdenda(externalStudyId, {
titulo: 'ADENDA', // el encabezado es tuyo
fecha: new Date(),
firma: miVisor.medicoActual(),
cuerpo: html, // o `text` si tu campo es texto plano
});
});
</script>openAddendum()devuelve una promesa contruesi empezó a grabar, ofalsesi ya había un dictado en curso (el médico tiene que terminarlo o cancelarlo antes).signedReportes opcional. Si tu sistema no puede mandarlo, la adenda funciona igual (ver qué se hace con el informe firmado).externalStudyIdse guarda tal cual en nuestro registro, para que soporte pueda ubicar la adenda, y vuelve en el evento para que sepas a qué estudio pertenece. No se cruza con nuestros estudios.- El resultado se muestra en el Widget en modo lectura, con Copiar, Insertar y Volver a dictar. Para corregirlo, el médico vuelve a dictar y el nuevo resultado reemplaza al anterior.
omniai:addendum(yonAddendumen elinit) se emite solo al tocar Insertar, una vez por toque: cada evento es una adenda para agregar.- El Widget también muestra un botón Adenda junto a "Omnimed AI", que abre una adenda sin informe firmado ni ID de estudio. Si preferís usar solo tu botón, ocultalo con
OmniWidget.init({ ..., addendumButton: false }).
Campo de omniai:addendum | Descripción |
|---|---|
| html | La adenda en HTML (párrafos, sin título) |
| text | La misma adenda en texto plano |
| transcription | El dictado transcripto |
| externalStudyId | El que pasaste a openAddendum, o null |
| recordingId | El mismo del omniai:audio de esa grabación (que llega con kind: "addendum") |
| eventId | ID del evento en nuestro registro, para soporte |
Renovación del token
El Widget renueva solo:
- Antes de cada llamada, si el token vence en menos de 30 segundos, pide uno nuevo con
getToken(). - Si igual la API responde
401, llama agetToken()una vez y repite la misma llamada con el mismo audio. - Si el segundo intento también da
401, muestra el error en el panel con un botón Reintentar. El dictado no se pierde: queda en el Widget hasta que el médico reintente, grabe otro o cierre el panel (y además ya se entregó a tu página poromniai:audioantes de subirlo).
Si getToken() lanza un error o no devuelve un texto, el Widget muestra "No se pudo obtener el token del Widget: ..." con el mismo botón Reintentar.
Ciclo de vida del Widget
| Estado | Significado |
|---|---|
| idle | Listo para grabar |
| recording | Micrófono activo |
| paused | Grabación en pausa |
| processing | Audio subido, generando informe |
| result | Informe visible en el panel |
| error | Falló el procesamiento o el token; el médico puede reintentar o volver a grabar |
Requisitos del navegador y de red
- HTTPS (requerido para acceder al micrófono).
- Permiso otorgado para
navigator.mediaDevices.getUserMedia. - Duración máxima de una grabación: 10 minutos.
- Si tu página usa Content Security Policy, permití
https://api.omniai.com.arenscript-src(el script del Widget) y enconnect-src(sus llamadas a la API). - El archivo
/static/widget/v2/omni-widget.min.jsrecibe correcciones compatibles sin cambiar de URL, así que por ahora no publicamos hashes de Subresource Integrity (SRI) para fijarlo.
API directa
Todas las llamadas de esta sección se hacen desde tu servidor con Authorization: Bearer omp_... (o desde el Widget con su token omw_...).
El Widget usa esta API para generar el informe (POST /api/omni-ai) y la adenda (POST /api/omni-ai/addendum). Para refinar no usa /api/omni-ai/refine: transcribe la corrección con /api/transcription/transcribe-upload y la aplica con /api/reports/refine, con el mismo token. Desde tu servidor, refiná con /api/omni-ai/refine.
Generar un informe
POST https://api.omniai.com.ar/api/omni-ai
Authorization: Bearer omp_...
Content-Type: multipart/form-dataParámetros
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
| file | File | Condicional | Archivo de audio (wav, mp3, m4a, ogg, flac, webm), hasta 100 MB. Requerido salvo que se envíe transcription_text |
| transcription_text | String | Condicional | Texto del dictado usado en lugar del audio (ver abajo). Requerido salvo que se envíe file |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| username | String | Recomendado | Médico declarado: el usuario del médico en el centro. La plantilla se elige entre las visibles para ese médico (generales + asignadas). Ausente o desconocido: solo generales. Se ignora si se envía template_html |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | No cambia el idioma: el dictado y el informe usan el idioma del centro. Solo se devuelve como language_detected cuando la entrada es transcription_text |
| metadata | String (JSON) | No | Metadata JSON libre, máx 10KB |
| dicom_metadata | String (JSON) | No | Estudio pedido, de la orden / headers DICOM; dispara los avisos de discrepancia (ver abajo) |
| template_html | String (HTML) | No | Plantilla preseleccionada: generar el informe contra exactamente esta plantilla (ver abajo) |
| template_name | String | No | Etiqueta de la plantilla preseleccionada; se devuelve como study_name / template_used |
| include_template | Boolean | No | Devolver el HTML de la plantilla que usó el servidor, para resaltar diferencias |
| include_rtf | Boolean | No | Devolver versión RTF de cada informe |
Nota: Sin template_html, el estudio se detecta automáticamente a partir de la transcripción y la plantilla se elige entre las plantillas guardadas del centro: no necesitás enviar el nombre del estudio.
Varios estudios en un dictado: reports siempre tiene una sola entrada. Si el médico dicta varios estudios, sus informes vienen concatenados en ese único report_html, study_name / template_used los listan separados por coma, y metadata.is_multi_study es true.
Enviar texto en lugar de audio (transcription_text)
Si tu sistema ya tiene el dictado como texto (tipeado, o transcripto de tu lado), envialo como transcription_text y omití el audio. Se omite el paso de transcripción y el texto entra al pipeline exactamente donde entraría la transcripción: la detección del estudio, la elección de plantilla y la generación se comportan igual. Se combina naturalmente con template_html (sin transcripción y sin elección de plantilla: el camino más rápido y totalmente determinístico).
- El audio siempre gana. Si enviás ambos, se transcribe el audio y
transcription_textse ignora por completo. No hay fallback: si el audio falla al transcribirse (p. ej. micrófono muteado) la llamada da error aunque hayas enviado texto. - Un campo
fileen blanco o untranscription_texten blanco ("" o espacios) se trata como ausente. No enviar ninguno devuelve un 400. - Texto plano, máx 50KB.
- En la respuesta:
duration_secondses0.0,speaker_countes1,language_detectedes el valor del campolanguage("spa"si no lo mandás), ymetadata.input_sourcees"text"(vs"audio"). - La facturación no cambia: un informe generado desde texto consume crédito exactamente igual que uno generado desde audio.
Enviar tu propia plantilla (template_html)
Cuando tu sistema sabe qué plantilla debe seguir el informe (por clínica, por centro derivador, por modalidad), enviala en la llamada:
- La detección del estudio y la elección de plantilla se omiten por completo: el informe sigue tu plantilla de forma determinística.
- El resultado es siempre un único informe, aunque el dictado mencione varios estudios.
- El HTML se sanitiza del lado del servidor antes de usarse; el tamaño máximo es 100KB. HTML demasiado grande, o markup que queda vacío tras la sanitización, devuelve un 400. Un campo en blanco ("" o espacios) se trata como ausente: la llamada sigue el camino normal de elección de plantilla.
- HTML es el formato recomendado: preserva negritas, títulos y estructura de bloques.
- En la respuesta:
template_match_confidencees1.0ymetadata.detection_sourcees"preselected_template". - Con
include_template=true, eltemplate_htmlde la respuesta es la plantilla sanitizada que el servidor usó realmente: hacé el diff contra esa, no contra tu original.
Respuesta exitosa
Una llamada sin template_html, con la plantilla elegida automáticamente:
{
"success": true,
"message": "Successfully processed audio and generated 1 report(s)",
"data": {
"transcription": "...",
"language_detected": "spa",
"duration_seconds": 42.1,
"speaker_count": 1,
"reports": [
{
"study_name": "IRM de Rodilla",
"template_used": "IRM de Rodilla",
"template_match_confidence": 0.91,
"report_html": "<p>...</p>",
"report_rtf": null,
"transcription_segment": "...",
"template_html": null
}
],
"warnings": [],
"context_checks": {
"laterality": {"status": "skipped", "reason": "no_context"},
"modality": {"status": "skipped", "reason": "no_context"},
"region": {"status": "skipped", "reason": "no_context"}
},
"processing_time_ms": 7441,
"metadata": {
"study_id": 12345,
"detection_source": "auto_detect",
"input_source": "audio",
"is_multi_study": false,
"...": "..."
},
"dicom_metadata_used": null
},
"timestamp": "2026-10-14T15:32:10.123456"
}data.reports[0].report_html: el informe.data.metadata.study_id: guardalo si vas a refinar este informe más adelante.data.dicom_metadata_used: eldicom_metadataque se usó, ya interpretado, onullsi no se mandó.metadatapuede sumar claves nuevas con el tiempo: parseá la respuesta de forma tolerante.
Avisos de discrepancia (lateralidad, modalidad, región)
Enviá el estudio pedido en dicom_metadata y la API lo compara con lo que dictó el médico. Una discrepancia vuelve en data.warnings; el informe se genera exactamente igual y nunca se modifica ni se bloquea por eso. Ninguno de los dos lados se asume correcto: la orden también puede estar mal.
{
"dicom_modality": "CT",
"dicom_bodypart": ["HAND"],
"dicom_laterality": "R",
"dicom_series_description": ["TC MANO DERECHA"]
}dicom_modality(requerido si se envíadicom_metadata): Modality DICOM (CT,MR,DX/CR,US,MG).dicom_bodypart: Body Part Examined DICOM, un valor (HAND,KNEE,SHOULDER,FOOT,BREAST, ...). La región se chequea en extremidades y mama; el resto todavía no.dicom_laterality:R,LoB(también se aceptaderecha/izquierda/bilateral). Si falta, el lado se lee dedicom_series_descriptioncuando lo dice en palabras (DER,IZQ,DERECHA,LEFT, ...).- El contexto libre, como la indicación clínica, va en
metadata: es contexto para el informe, no entrada de estos chequeos.
Cada aviso:
{
"code": "LATERALITY_MISMATCH",
"study_name": "custom",
"context_value": "R",
"dictated_value": "L",
"evidence": "quinto metacarpiano del pie izquierdo",
"message": "El estudio está indicado como derecho y se dictó izquierdo."
}code:LATERALITY_MISMATCH,MODALITY_MISMATCHoREGION_MISMATCH.context_value/dictated_value: lateralidadR/L; modalidadRX,TC,RM,ECO,MAMO; región una clave en español comomano,pie,rodilla,muneca.evidence: fragmento literal del dictado, cuando lo hay.message: en español, listo para mostrarle al radiólogo.
context_checks indica, por chequeo, si corrió: ok, mismatch, o skipped con un reason (no_context: no se envió; unmapped: un valor que todavía no chequeamos; not_dictated: el médico no lo dijo; bilateral_context: la orden es bilateral; llm_unavailable: el chequeo no llegó a terminar a tiempo). Un warnings vacío con chequeos skipped significa "no verificado", no "verificado OK".
Los chequeos están calibrados para evitar falsas alarmas: ante la duda no avisan. Mencionar el otro lado para comparar ("comparado con la rodilla izquierda") u otra modalidad como sugerencia ("se sugiere RM") no son discrepancias.
Ejemplos
Todo campo que lleve HTML (template_html, report_html, signed_report) va con --form-string en cURL: con -F, un valor que empieza con < o @ se interpreta como un archivo a leer.
cURL
curl -X POST https://api.omniai.com.ar/api/omni-ai \
-H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
-F "[email protected]" \
-F "medical_center_id=YOUR_CENTER_ID" \
-F "username=dr.martinez"cURL (plantilla preseleccionada)
curl -X POST https://api.omniai.com.ar/api/omni-ai \
-H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
-F "[email protected]" \
-F "medical_center_id=YOUR_CENTER_ID" \
-F "username=dr.martinez" \
--form-string "template_html=<h2>IRM de Rodilla</h2><p>...</p>" \
-F "template_name=IRM de Rodilla" \
-F "include_template=true"Python
import os
import requests
url = "https://api.omniai.com.ar/api/omni-ai"
headers = {"Authorization": f"Bearer {os.environ['OMNI_PARTNER_CREDENTIAL']}"}
with open("audio.m4a", "rb") as f:
response = requests.post(
url,
headers=headers,
files={"file": f},
data={
"medical_center_id": "YOUR_CENTER_ID",
"username": "dr.lopez",
},
timeout=120,
)
response.raise_for_status()
result = response.json()
for report in result["data"]["reports"]:
print(report["study_name"], "->", report["report_html"])JavaScript (Node 18+, en tu servidor)
async function processAudio(audioBlob, centerId, username) {
const form = new FormData();
form.append('file', audioBlob, 'dictado.webm');
form.append('medical_center_id', centerId);
form.append('username', username);
const res = await fetch('https://api.omniai.com.ar/api/omni-ai', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OMNI_PARTNER_CREDENTIAL}` },
body: form,
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
// Errores de credencial: err.detail.code; el resto: err.error
throw new Error(err.detail?.code || err.error || `HTTP ${res.status}`);
}
const { data } = await res.json();
return data.reports[0].report_html;
}Refinar un informe
POST https://api.omniai.com.ar/api/omni-ai/refine
Authorization: Bearer omp_...
Content-Type: multipart/form-dataEl médico dicta una corrección sobre un informe ya generado ("cambiar menisco interno por menisco externo"). El servidor transcribe el audio, cambia solo el elemento apuntado (más la Conclusión del informe cuando el cambio la afecta) y devuelve el informe actualizado. El refinamiento nunca regenera el informe completo, y no se factura.
Parámetros
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
| file | File | Condicional | Audio con la corrección dictada. Requerido salvo que se envíe transcription_text |
| transcription_text | String | Condicional | La corrección como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente) |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| report_html | String (HTML) | Sí | El último informe generado, exactamente como lo devolvió la API (o como quedó tras el último refinamiento) |
| template_html | String (HTML) | No | La plantilla original del informe; se usa como referencia de estilo/formato de solo lectura (máx 100KB) |
| template_name | String | No | Nombre de la plantilla/tipo de estudio, habilita reglas por tipo de estudio |
| selected_html | String (HTML) | No | Elemento HTML exacto a refinar; omitilo para que el modelo ubique el objetivo desde el dictado |
| study_id | Integer | No | El metadata.study_id de la llamada de generación, para trazar el refinamiento |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | Se acepta por compatibilidad y se ignora: se usa el idioma del centro |
Respuesta exitosa
{
"success": true,
"message": "Successfully refined report",
"data": {
"transcription": "cambiar menisco interno por menisco externo",
"report_html": "<p>...informe completo con la corrección aplicada...</p>",
"patch": {
"refined_text": "<p>Menisco externo sin alteraciones.</p>",
"target_html": "<p>Menisco interno sin alteraciones.</p>",
"conclusion_text": null,
"conclusion_original_text": null
},
"processing_time_ms": 3120,
"metadata": {
"medical_center_id": "your-center",
"study_id": 12345,
"event_id": "678",
"...": "..."
}
},
"timestamp": "2026-10-14T15:34:02.456789"
}data.report_htmles el informe completo con la corrección ya aplicada: usalo directamente.data.patchdescribe las regiones exactas que cambiaron, si preferís empalmarlas en tu propio editor: reemplazá la primera ocurrencia detarget_htmlporrefined_text, y la deconclusion_original_textporconclusion_text(cuando no sea null).- Para encadenar refinamientos, enviá el
report_htmldevuelto como entrada de la siguiente llamada de refine.
Ejemplo
curl -X POST https://api.omniai.com.ar/api/omni-ai/refine \
-H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
-F "[email protected]" \
-F "medical_center_id=YOUR_CENTER_ID" \
--form-string "report_html=<p>...último informe generado...</p>" \
--form-string "template_html=<p>...plantilla original...</p>" \
-F "study_id=12345"Resaltar los cambios de la IA (diff en el cliente)
El Widget resalta lo que cambió la IA haciendo un diff entre la plantilla y el informe generado. Podés hacer lo mismo en tu interfaz con jsdiff (diff en npm), sin soporte del servidor:
import { diffWords } from 'diff';
function highlightChanges(templateText, reportText) {
return diffWords(templateText, reportText)
.map((part) =>
part.added
? `<mark class="ai-change">${part.value}</mark>`
: part.removed
? ''
: part.value
)
.join('');
}- Línea base: pedí
include_template=trueen la generación y hacé el diff contra eltemplate_htmldevuelto. - Después de un refine, hacé el diff entre el
report_htmlanterior y el nuevo para resaltar solo la corrección. - Hacé el diff sobre el texto visible (p. ej. vía
DOMParser) en lugar de strings HTML crudos para resaltados más limpios.
Agregar una adenda a un informe firmado
POST https://api.omniai.com.ar/api/omni-ai/addendum
Authorization: Bearer omp_...
Content-Type: multipart/form-dataEl médico ya firmó el informe en tu sistema y dicta algo para agregarle (un hallazgo que faltó, o la comparación cuando el paciente trae estudios previos). El servidor devuelve solo lo dictado, en párrafos sin título y en el idioma de informes del centro. Tu sistema le pone su propio encabezado ("ADENDA"), fecha y firma y lo agrega; el informe firmado nunca se modifica. No se elige plantilla y las adendas no se facturan. Desde el Widget es el mismo flujo con OmniWidget.openAddendum (ver Adenda sobre un informe ya firmado).
El informe firmado es solo referencia. signed_report (signedReport en el Widget) se usa únicamente para nombrar lo que el médico menciona con los términos del informe ("el nódulo de arriba" sale como lo nombra el informe). Nunca se repite ni se resume, y nunca se copian medidas ni hallazgos que el médico no dictó.
Parámetros
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
| file | File | Condicional | Audio con la adenda dictada. Requerido salvo que se envíe transcription_text |
| transcription_text | String | Condicional | La adenda como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente) |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| username | String | Recomendado | Médico declarado; queda registrado en la adenda |
| signed_report | String (HTML o texto) | No | El informe firmado tal como lo tiene tu sistema, ediciones manuales incluidas (máx 100KB) |
| external_study_id | String | No | Tu propio identificador del estudio, guardado tal cual, para ubicar la adenda en soporte |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | Se acepta por compatibilidad y se ignora: se usa el idioma del centro |
Respuesta exitosa
{
"success": true,
"message": "Successfully generated addendum",
"data": {
"addendum_html": "<p>En la región mamaria derecha se identifica una lesión nodular de 12 x 9 mm.</p>",
"transcription": "esto es una adenda, agregar una nota de que en la región mamaria derecha se identifica lesión nodular de 12 por 9 milímetros",
"processing_time_ms": 2410,
"metadata": {
"medical_center_id": "your-center",
"event_id": "678",
"audio_id": null,
"external_study_id": "ACC-00042",
"input_source": "audio",
"...": "..."
}
},
"timestamp": "2026-10-14T15:40:11.789012"
}data.addendum_html: la adenda, lista para agregar debajo de tu encabezado.data.transcription: el dictado transcripto (o eltranscription_textque enviaste).- Para corregir una adenda, el médico la vuelve a dictar: no hay refine de adendas.
Ejemplo
curl -X POST https://api.omniai.com.ar/api/omni-ai/addendum \
-H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
-F "[email protected]" \
-F "medical_center_id=YOUR_CENTER_ID" \
-F "username=dr.perez" \
--form-string "signed_report=<p>...informe firmado...</p>" \
-F "external_study_id=ACC-00042"Errores
Los errores devuelven un status no-2xx. Hay tres formas de cuerpo; distinguilas por la clave de primer nivel y por el tipo de detail.
Errores de credencial y token
detail es un objeto. Ramificá por detail.code, que es estable; detail.message es texto para humanos y puede cambiar:
{
"detail": {
"code": "center_not_enabled",
"message": "La Partner Credential no está habilitada para el centro 'your-center'."
}
}| Status | detail.code | Qué significa | Qué hacer |
|---|---|---|---|
| 401 | missing_partner_credential | POST /api/widget/token sin Authorization: Bearer omp_... | Mandá la credencial como Bearer desde tu servidor |
| 401 | invalid_partner_credential | Credencial desconocida (mal copiada o rotada) | Revisá el valor; si se rotó, usá la nueva |
| 401 | partner_credential_revoked | Credencial revocada. Los tokens del Widget que emitió dejan de valer en menos de 60 segundos | Pedinos una credencial nueva |
| 401 | widget_token_expired | Token del Widget vencido | El Widget lo renueva solo si tiene getToken |
| 401 | invalid_widget_token | Token del Widget alterado o de otro servidor | Pedí un token nuevo con POST /api/widget/token |
| 403 | center_not_enabled | Tu credencial no está habilitada para ese medical_center_id | Es configuración de nuestro lado: escribinos |
| 503 | widget_token_not_configured | Problema de nuestro lado al emitir o validar tokens | Reintentá con backoff |
| 503 | partner_lookup_unavailable | No pudimos verificar la credencial en este momento. No es un rechazo | Reintentá con backoff |
Errores generales
Entrada, centro y procesamiento, con la misma forma que en v1 más un timestamp:
{
"success": false,
"error": "Report generation failed: ...",
"error_code": 500,
"timestamp": "2026-10-14T15:32:10.123456"
}error_code suele repetir el status HTTP: ramificá por el status. El código propio que conviene conocer es 1011 (el centro no tiene plantillas), que llega con status 422. error es texto para humanos y puede cambiar.
| Status | Causa | Qué hacer |
|---|---|---|
| 400 | Falta la entrada (ni file ni transcription_text); audio de menos de 1KB; la extensión del archivo no coincide con su contenido; formato de audio no soportado; metadata que no es JSON o pasa de 10KB; transcription_text de más de 50KB; template_html o signed_report de más de 100KB, o template_html vacío tras sanitizar; report_html en blanco en refine | Corregí la llamada; no tiene sentido reintentarla igual |
| 403 | Centro deshabilitado | Contactanos |
| 422 | El centro no tiene plantillas cargadas (error_code 1011) | Escribinos para cargar las plantillas del centro |
| 500 | Falló la transcripción o la generación del informe, incluido un audio sin dictado (transcripción vacía, p. ej. micrófono muteado) | Revisá la grabación; reintentá con backoff; si persiste, escribinos con el timestamp |
| 502 | El modelo devolvió una adenda vacía | Volvé a dictar la adenda |
| 503 | En refine y adenda: el modelo no está disponible en este momento (error_code 503) | Reintentá con backoff |
Errores de validación
Un campo requerido que falta o con un tipo inválido (p. ej. medical_center_id sin token del Widget, report_html en refine, o username en POST /api/widget/token) devuelve 422 con detail como lista, uno por campo:
{
"detail": [
{"type": "missing", "loc": ["body", "medical_center_id"], "msg": "Field required"}
]
}Límites y operación
| Límite | Valor |
|---|---|
Audio (file) | Hasta 100 MB; mínimo 1KB |
| Grabación del Widget | 10 minutos |
transcription_text | 50KB |
metadata | 10KB |
template_html, signed_report | 100KB |
| Vida del token del Widget | 1 hora |
- Timeout: la generación puede tardar decenas de segundos en dictados largos. Configurá un timeout de cliente de 120 segundos o más, que es el timeout del servidor.
- Rate limit: no hay un límite por partner publicado. Reintentá los 5xx con backoff exponencial; los 4xx no se reintentan sin corregir la llamada.
- Créditos: cada informe generado consume un crédito. Una llamada que falla no consume crédito; una que tuvo éxito y se repite consume otro. Refinar y agregar adendas no consume créditos.
- Tokens: pedí un token del Widget por médico y cachealo hasta
expires_at; no hace falta pedir uno por llamada.
Health check
Para verificar conectividad desde tu backend. No requiere credencial:
curl https://api.omniai.com.ar/api/healthRespuesta:
{
"status": "healthy",
"services": {
"api": {
"service": "api",
"status": "healthy",
"available": true
}
}
}Alta de un partner
- Credencial. Omnimed AI emite una Partner Credential por partner y la habilita para sus centros. Pedila a [email protected].
- Centros. Cada centro lo crea Omnimed AI, con su
medical_center_id, y carga sus plantillas a partir del pack de plantillas que manda el centro. - Médicos. Los médicos (los
username) se dan de alta en el panel de administración del centro. Unusernameque el centro no conoce funciona igual, pero solo con las plantillas generales. - Pruebas. A pedido, habilitamos un centro de prueba para integrar sin tocar datos de un centro real.
Contacto: [email protected]
Migrar de v1 a v2
| v1 | v2 | |
|---|---|---|
| Autenticación de la API directa | Ninguna: solo medical_center_id | Authorization: Bearer omp_... + medical_center_id + username |
| Script del Widget | /static/widget/v1/omni-widget.min.js | /static/widget/v2/omni-widget.min.js |
| Centro y médico en el Widget | data-center-id / data-username en la página | Dentro del token, emitido por tu backend |
| Autenticación del Widget | Ninguna | Authorization: Bearer omw_... en cada llamada, renovado con getToken() |
| Adendas | No | POST /api/omni-ai/addendum y OmniWidget.openAddendum |
| Errores de credencial | No existen | {"detail": {"code", "message"}} |
| Parámetros, respuestas e informes | Iguales |
Centros que ya usaban v1
Los centros que ya llamaban sin credencial (v1, solo medical_center_id) siguen funcionando así mientras migran. Los centros nuevos tienen que usar una Partner Credential. El acceso sin credencial se cierra centro por centro, con aviso previo por email de al menos 90 días.
Pasos
- Pedinos una Partner Credential a [email protected] y guardala como variable de entorno de tu servidor.
- API directa: agregá el header
Authorization: Bearer omp_...y mandáusernamecon el médico de tu sesión en cada llamada. - Widget: sumá un endpoint de tu backend que devuelva el token para el médico de la sesión, cambiá el script a
/static/widget/v2/omni-widget.min.js, sacádata-center-id/data-usernamedel tag (si quedan, el Widget arranca en modo v1 e ignora tuinit) y llamá aOmniWidget.init({ getToken, onReport }). - Manejá los errores por
detail.code(sobre todocenter_not_enabledypartner_credential_revoked). - Probá dictar, refinar y dejar pasar una hora para ver la renovación del token.
- Si usás adendas: botón propio que llama a
OmniWidget.openAddendum({ signedReport, externalStudyId })y listener deomniai:addendumque la agrega con tu encabezado.
La documentación de v1 sigue en /api-documentation/v1.
Changelog
v2 (2026-10-14)
- Partner Credential (
omp_...) para llamadas de servidor a servidor, conusernamecomo médico declarado. - Token del Widget por médico (
omw_...) conPOST /api/widget/token, y Widget v2 en/static/widget/v2/omni-widget.min.jscongetToken. - Adendas sobre informes ya firmados:
POST /api/omni-ai/addendumyOmniWidget.openAddendum. - Errores de credencial con un
detail.codeestable. - Los parámetros, las respuestas y los informes de v1 no cambian.
v1
- Widget v1 y API directa autenticados solo por
medical_center_id. Ver /api-documentation/v1.
Soporte
Consultas, Partner Credentials y alta de centros: [email protected]

