Omnimed AI

    Omnimed AI Integration Guide

    Integrate Omnimed AI report dictation into your RIS, HIS or viewer.

    1. Widget

    Drop a <script> v2 tag into your page and give it getToken: your backend exchanges the Partner Credential for a 1-hour, per-doctor token. The report can be auto-inserted into the active editor.

    Use this if: you want the fastest integration and are happy with the default recording UI.

    2. Direct API

    POST an audio file to /api/omni-ai from your server, with your Partner Credential as a Bearer, and handle the response yourself.

    Use this if: you need full control over the recording UI or are integrating from a native/desktop client.

    Quick start

    Base URL: https://api.omniai.com.ar

    1. Request your Partner Credential from [email protected]. It is an omp_... secret shown only once: store it as an environment variable on your server. See Onboarding a partner.

    2. Make the first call. From your server, with a test recording:

      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"

      To use the Widget in the browser, your backend exchanges the credential for a per-doctor token and the page receives it through OmniWidget.init({ getToken }) (see Widget v2).

    3. Read the response. The report is in data.reports[0].report_html; keep data.metadata.study_id if you will refine it later (see Success response).

    Authentication

    Each integration partner receives from Omnimed AI a Partner Credential (omp_...), enabled for one or more centers. Each center has its medical_center_id, which identifies its templates, language and report style.

    Partner Credential (server-to-server)

    When your server calls the API directly, it sends the credential as a Bearer on every call and names the center and the doctor in the form:

    • Authorization: Bearer omp_...: your Partner Credential. Only on your server (environment variable, secret manager); never in the browser.
    • medical_center_id: a center your credential is enabled for.
    • username: the declared user (see below).

    /api/omni-ai/refine and /api/omni-ai/addendum take the same header and the same medical_center_id.

    An Authorization: Bearer header whose value starts with neither omp_ nor omw_ is ignored: the call is treated as carrying no credential.

    Widget token

    The Widget runs in the doctor's browser, so it never receives the Partner Credential. Your backend exchanges it for a Widget token (omw_...) with POST /api/widget/token, for one center and one doctor, and hands it to the page. The token lasts one hour and is valid for a single doctor of a single center. The Widget sends it as Authorization: Bearer omw_... on every call and, when it expires, asks your page for a new one through getToken().

    Widget token flow: the Widget calls getToken() on your backend; your backend requests the token from POST /api/widget/token with its Partner Credential; the API returns a one-hour omw_ token; the Widget uses it as a Bearer for dictation, refinement and addenda, and gets a new one when it expires or receives a 401

    With a Widget token, the center and the doctor come from the token: if the call also sends medical_center_id or username, they are ignored.

    A token is not revoked on its own: it expires after an hour. What gets revoked or rotated is the Partner Credential, and the tokens it issued stop working in under 60 seconds.

    Declared user (username)

    username is the doctor's username in the center (the same login they use in the desktop app). Your system declares it with its credential: we do not authenticate that doctor, we take your word for it within the centers you are enabled for. It decides which templates the dictation's template is chosen from:

    usernameTemplates the choice is made from
    absent (direct API only)The center's general templates only
    a user of the centerThe general ones plus those assigned to that doctor (directly or through a group)
    a user the center does not knowGeneral templates only; the call is tracked under the declared name

    A user is never created from a username. Decide it in your backend from your own session, never from something the browser sends: that way a doctor cannot act on behalf of another.

    Widget v2

    1. Your backend requests the token

    POST https://api.omniai.com.ar/api/widget/token
    Authorization: Bearer omp_...
    Content-Type: application/json
    WhereFieldRequiredDescription
    HeaderAuthorization: Bearer omp_...YesYour Partner Credential. Only on your server
    JSON bodymedical_center_idYesThe center the Widget acts for (the same ID as in v1). Must be enabled for your credential
    JSON bodyusernameYesThe doctor using the Widget, by their username in the center (declared user). A token is per doctor
    JSON bodyuser_idNoYour own doctor ID; only recorded

    200 response:

    {
      "token": "omw_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "expires_at": "2026-10-14T18:32:10Z",
      "expires_in": 3600
    }
    • token: the Widget token, to hand to the page.
    • expires_at: expiry, ISO 8601 (UTC). You can cache the token per doctor until then instead of requesting one on every page load.
    • expires_in: seconds until expiry.

    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)

    An endpoint on your backend that returns the token as plain text for the signed-in doctor. Uses native fetch (Node 18+).

    // OMNI_PARTNER_CREDENTIAL=omp_...  (environment variable, never in the front end)
    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, // e.g. 'YOUR_CENTER_ID'
          username: req.user.username,              // the doctor of this session
          user_id: String(req.user.id),             // optional
        }),
      });
      if (!r.ok) {
        // 401: invalid or revoked credential; 403: center not enabled (see Errors)
        return res.status(502).send(await r.text());
      }
      const { token } = await r.json();
      res.type('text/plain').send(token);
    });

    The Widget calls getToken() only right before its first upload, not when the page loads: a misconfigured token endpoint only shows up once the doctor has finished dictating. Test it from your backend (for example with the cURL above) before going live.

    2. Initialize the Widget in the page

    <script src="https://api.omniai.com.ar/static/widget/v2/omni-widget.min.js"></script>
    <script>
      OmniWidget.init({
        // Called by the Widget before the first upload and whenever the token expires.
        getToken: () =>
          fetch('/omni-token', { credentials: 'include' }).then((r) => {
            if (!r.ok) throw new Error('Could not get the Omnimed AI token');
            return r.text();
          }),
        onReport: ({ html, text, recordingId }) => {
          // Insert the report into your editor (see "Auto-insert the report")
        },
      });
    </script>

    A floating recording button is injected in the bottom-right corner. The doctor taps it, dictates, stops, and the Widget shows the generated report.

    No data-center-id on the <script>. If the tag still carries data-center-id (from a v1 integration), the Widget starts on its own in v1 mode when it loads, and a later OmniWidget.init({ getToken }) is ignored with an "Already initialized" warning in the console. In v2 the center and the doctor come from the token; centerId or username passed to init are ignored.

    • If you already have a token when rendering the page, you can pass it too: OmniWidget.init({ token: 'omw_...', getToken }). The Widget uses it first and only calls getToken() when it expires.
    • With token alone, without getToken, the Widget works for one hour and then shows an error. Always pass getToken.
    • For a different doctor (user switch in your product), call OmniWidget.destroy() and init again with a getToken that fetches the new doctor's token.
    init optionDescription
    getTokenFunction that returns (or resolves to) the omw_... token as text. Always recommended
    tokenInitial token, optional
    apiUrlAPI base URL. Defaults to the origin the script was loaded from (https://api.omniai.com.ar)
    onReportCallback with the report, same as the omniai:report event
    onAudioCallback with the raw recording, same as the omniai:audio event
    onAddendumCallback with the addendum, same as the omniai:addendum event
    addendumButtonfalse hides the Widget's Addendum button (shown by default)

    Auto-insert the report

    The Widget emits omniai:report (and calls onReport) when the report is ready, every time the doctor clicks Insert, and after every refinement. event.detail carries html, text and the dictation's recordingId. Since the same dictation can arrive several times, your code must replace what it already inserted for that recordingId instead of adding another copy:

    // One container per dictation: the next event for the same recordingId replaces it.
    window.addEventListener('omniai:report', (event) => {
      const { html, recordingId } = event.detail;
      const editor = document.querySelector('#report');
      let block = editor.querySelector(`[data-omni-recording="${recordingId}"]`);
      if (!block) {
        block = document.createElement('div');
        block.dataset.omniRecording = recordingId;
        editor.appendChild(block);
      }
      block.innerHTML = html; // report HTML, sanitised server-side
    });

    Keep a copy of the audio

    If your system must retain the original dictation (e.g. legal retention in the PACS), the Widget hands you the raw recording the moment the doctor stops, before uploading it, so your copy exists even if processing fails. Listen for omniai:audio or pass onAudio to init:

    window.addEventListener('omniai:audio', (event) => {
      const { blob, mimeType, kind, recordingId, parentRecordingId } = event.detail;
      // e.g. upload the blob to your storage, with your study ID + recordingId
    });
    FieldDescription
    blobThe recording (Blob), usually WebM/Opus (Ogg or MP4 in browsers without WebM)
    mimeTypeMIME type of the blob
    durationSecondsLength of the recording
    sizeBytesSize of the blob
    kind"dictation", "refinement" (a spoken correction) or "addendum"
    recordingIdUnique ID of this recording. The omniai:report and omniai:addendum payloads carry the same recordingId, linking audio and result
    parentRecordingIdOn a refinement, the recordingId of the dictation it corrects; null otherwise
    recordedAtISO 8601 timestamp

    The blob does not leave your page through this event: storing it is up to your code.

    Addendum on an already-signed report

    When the doctor has already signed the report in your system and needs to add something to it (a missed finding, or the comparison with prior studies the patient brought), they open an addendum instead of dictating a new report. The Widget records and the API returns only what was dictated, as paragraphs, with no title and in the center's report language; you append it to the report with your own heading, date and signature. The signed report is never changed. No template is chosen, there is no "Refine" and addenda do not consume credits.

    <button id="add-addendum">Add addendum</button>
    <script>
      // 1. Your button opens the addendum and the Widget starts recording right away.
      document.getElementById('add-addendum').addEventListener('click', () => {
        OmniWidget.openAddendum({
          signedReport: myViewer.signedReportHtml(), // optional: HTML or text, as you hold it
          externalStudyId: myViewer.accessionNumber(), // optional: your study ID
        });
      });
    
      // 2. When the doctor clicks "Insert", the addendum arrives for you to append.
      window.addEventListener('omniai:addendum', (e) => {
        const { html, text, externalStudyId, transcription } = e.detail;
        myViewer.appendAddendum(externalStudyId, {
          title: 'ADDENDUM',                    // the heading is yours
          date: new Date(),
          signature: myViewer.currentDoctor(),
          body: html,                           // or `text` if your field is plain text
        });
      });
    </script>
    • openAddendum() returns a promise resolving to true if it started recording, or false if a dictation was already in progress (the doctor has to finish or cancel it first).
    • signedReport is optional. If your system cannot send it, the addendum works the same (see what the signed report is used for).
    • externalStudyId is stored as-is in our records, so support can locate the addendum, and comes back in the event so you know which study it belongs to. It is never matched against our studies.
    • The result is shown in the Widget read-only, with Copy, Insert and Dictate again. To correct it, the doctor dictates again and the new result replaces the previous one.
    • omniai:addendum (and onAddendum in init) fires only when Insert is clicked, once per click: each event is one addendum to append.
    • The Widget also shows an Addendum button next to "Omnimed AI", which opens an addendum with no signed report or study ID. If you prefer to use only your own button, hide it with OmniWidget.init({ ..., addendumButton: false }).
    omniai:addendum fieldDescription
    htmlThe addendum as HTML (paragraphs, no title)
    textThe same addendum as plain text
    transcriptionThe transcribed dictation
    externalStudyIdThe one you passed to openAddendum, or null
    recordingIdThe same as the omniai:audio of that recording (which arrives with kind: "addendum")
    eventIdEvent ID in our records, for support

    Token renewal

    The Widget renews on its own:

    1. Before each call, if the token expires in less than 30 seconds, it requests a new one with getToken().
    2. If the API still answers 401, it calls getToken() once and repeats the same call with the same audio.
    3. If the second attempt also returns 401, it shows the error in the panel with a Retry button. The dictation is not lost: it stays in the Widget until the doctor retries, records another one or closes the panel (and it was already handed to your page through omniai:audio before upload).

    If getToken() throws or does not return text, the Widget shows "No se pudo obtener el token del Widget: ..." with the same Retry button.

    Widget lifecycle

    StateMeaning
    idleReady to record
    recordingMicrophone active
    pausedRecording paused
    processingAudio uploaded, generating report
    resultReport visible in the panel
    errorProcessing or the token failed; the doctor can retry or record again

    Browser and network requirements

    • HTTPS (required for microphone access).
    • Permission granted for navigator.mediaDevices.getUserMedia.
    • Maximum length of a recording: 10 minutes.
    • If your page uses a Content Security Policy, allow https://api.omniai.com.ar in script-src (the Widget script) and in connect-src (its API calls).
    • The /static/widget/v2/omni-widget.min.js file receives backward-compatible fixes without changing URL, so for now we do not publish Subresource Integrity (SRI) hashes to pin it.

    Direct API

    Every call in this section is made from your server with Authorization: Bearer omp_... (or from the Widget with its omw_... token).

    The Widget uses this API to generate the report (POST /api/omni-ai) and the addendum (POST /api/omni-ai/addendum). It does not refine through /api/omni-ai/refine: it transcribes the correction with /api/transcription/transcribe-upload and applies it with /api/reports/refine, with the same token. From your server, refine with /api/omni-ai/refine.

    Generate a report

    POST https://api.omniai.com.ar/api/omni-ai
    Authorization: Bearer omp_...
    Content-Type: multipart/form-data

    Parameters

    ParameterTypeRequiredDescription
    fileFileConditionalAudio file (wav, mp3, m4a, ogg, flac, webm), up to 100 MB. Required unless transcription_text is sent
    transcription_textStringConditionalDictation text used instead of audio (see below). Required unless file is sent
    medical_center_idStringYesA center enabled for your credential. Not needed with a Widget token, and ignored
    usernameStringRecommendedDeclared user: the doctor's username in the center. The template is chosen from those visible to that doctor (general + assigned). Absent or unknown: general only. Ignored when template_html is sent
    user_idStringNoDoctor/user identifier for analytics
    languageStringNoDoes not change the language: dictation and report use the center's language. Only echoed back as language_detected when the input is transcription_text
    metadataString (JSON)NoFree-form JSON metadata, max 10KB
    dicom_metadataString (JSON)NoThe ordered study, from the order / DICOM headers; triggers the mismatch warnings (see below)
    template_htmlString (HTML)NoPreselected template: generate the report against exactly this template (see below)
    template_nameStringNoLabel of the preselected template; echoed back as study_name / template_used
    include_templateBooleanNoReturn the HTML of the template the server used, for diff highlighting
    include_rtfBooleanNoReturn an RTF version of each report

    Note: Without template_html, the study is detected automatically from the transcription and the template is chosen from the center's stored templates: you do not need to send the study name.

    Several studies in one dictation: reports always has one entry. When the doctor dictates several studies, their reports come concatenated in that single report_html, study_name / template_used list them comma-separated, and metadata.is_multi_study is true.

    Sending text instead of audio (transcription_text)

    If your system already has the dictation as text (typed, or transcribed on your side), send it as transcription_text and omit the audio. The transcription step is skipped and the text enters the pipeline exactly where the transcription would: study detection, template choice and generation behave the same. It combines naturally with template_html (no transcription and no template choice: the fastest, fully deterministic path).

    • Audio always wins. If you send both, the audio is transcribed and transcription_text is ignored entirely. There is no fallback: if the audio fails to transcribe (e.g. a muted microphone) the call errors even though you sent text.
    • A blank file field or a blank transcription_text ("" or whitespace) is treated as absent. Sending neither returns a 400.
    • Plain text, max 50KB.
    • In the response: duration_seconds is 0.0, speaker_count is 1, language_detected is the value of the language field ("spa" if you do not send it), and metadata.input_source is "text" (vs "audio").
    • Billing does not change: a report generated from text consumes a credit exactly like one generated from audio.

    Sending your own template (template_html)

    When your system knows which template the report must follow (per clinic, per referring center, per modality), send it in the call:

    • Study detection and template choice are skipped entirely: the report follows your template deterministically.
    • The result is always a single report, even if the dictation mentions several studies.
    • The HTML is sanitised server-side before use; the maximum size is 100KB. HTML that is too large, or markup that is empty after sanitisation, returns a 400. A blank field ("" or whitespace) is treated as absent: the call follows the normal template-choice path.
    • HTML is the recommended format: it preserves bold, headings and block structure.
    • In the response: template_match_confidence is 1.0 and metadata.detection_source is "preselected_template".
    • With include_template=true, the response's template_html is the sanitised template the server actually used: diff against that one, not your original.

    Success response

    A call without template_html, with the template chosen automatically:

    {
      "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: the report.
    • data.metadata.study_id: keep it if you will refine this report later.
    • data.dicom_metadata_used: the dicom_metadata that was used, as parsed, or null if none was sent.
    • metadata may gain new keys over time: parse the response tolerantly.

    Mismatch warnings (laterality, modality, region)

    Send the ordered study in dicom_metadata and the API compares it with what the doctor dictated. A discrepancy comes back in data.warnings; the report is generated exactly the same and is never changed or blocked because of it. Neither side is assumed correct: the order can be wrong too.

    {
      "dicom_modality": "CT",
      "dicom_bodypart": ["HAND"],
      "dicom_laterality": "R",
      "dicom_series_description": ["TC MANO DERECHA"]
    }
    • dicom_modality (required if dicom_metadata is sent): DICOM Modality (CT, MR, DX/CR, US, MG).
    • dicom_bodypart: DICOM Body Part Examined, one value (HAND, KNEE, SHOULDER, FOOT, BREAST, ...). Region is checked for extremities and breast; the rest not yet.
    • dicom_laterality: R, L or B (derecha / izquierda / bilateral also accepted). If absent, the side is read from dicom_series_description when it spells it out (DER, IZQ, DERECHA, LEFT, ...).
    • Free-form context, such as the clinical indication, goes in metadata: it is context for the report, not input to these checks.

    Each warning:

    {
      "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_MISMATCH or REGION_MISMATCH.
    • context_value / dictated_value: laterality R/L; modality RX, TC, RM, ECO, MAMO; region a Spanish key such as mano, pie, rodilla, muneca.
    • evidence: literal fragment of the dictation, when there is one. message: in Spanish, ready to show the radiologist.

    context_checks says, per check, whether it ran: ok, mismatch, or skipped with a reason (no_context: not sent; unmapped: a value we do not check yet; not_dictated: the doctor did not say it; bilateral_context: the order is bilateral; llm_unavailable: the check did not finish in time). An empty warnings with skipped checks means "not verified", not "verified OK".

    The checks are calibrated to avoid false alarms: when in doubt they stay silent. Mentioning the other side for comparison ("compared with the left knee") or another modality as a suggestion ("MRI is suggested") are not discrepancies.

    Examples

    Every field that carries HTML (template_html, report_html, signed_report) goes with --form-string in cURL: with -F, a value starting with < or @ is read as a file.

    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 (preselected template)

    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+, on your server)

    async function processAudio(audioBlob, centerId, username) {
      const form = new FormData();
      form.append('file', audioBlob, 'dictation.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(() => ({}));
        // Credential errors: err.detail.code; everything else: err.error
        throw new Error(err.detail?.code || err.error || `HTTP ${res.status}`);
      }
    
      const { data } = await res.json();
      return data.reports[0].report_html;
    }

    Refine a report

    POST https://api.omniai.com.ar/api/omni-ai/refine
    Authorization: Bearer omp_...
    Content-Type: multipart/form-data

    The doctor dictates a correction to an already generated report ("cambiar menisco interno por menisco externo"). The server transcribes the audio, changes only the targeted element (plus the report's Conclusion when the change affects it) and returns the updated report. Refinement never regenerates the whole report, and it is not billed.

    Parameters

    ParameterTypeRequiredDescription
    fileFileConditionalAudio with the dictated correction. Required unless transcription_text is sent
    transcription_textStringConditionalThe correction as text instead of audio (max 50KB, same rules as generation: audio wins, blank = absent)
    medical_center_idStringYesA center enabled for your credential. Not needed with a Widget token, and ignored
    report_htmlString (HTML)YesThe last generated report, exactly as the API returned it (or as it stood after the last refinement)
    template_htmlString (HTML)NoThe report's original template; used as a read-only style/format reference (max 100KB)
    template_nameStringNoTemplate/study-type name, enables per-study-type rules
    selected_htmlString (HTML)NoExact HTML element to refine; omit it to let the model locate the target from the dictation
    study_idIntegerNoThe metadata.study_id from the generation call, to trace the refinement
    user_idStringNoDoctor/user identifier for analytics
    languageStringNoAccepted for compatibility and ignored: the center's language is used

    Success response

    {
      "success": true,
      "message": "Successfully refined report",
      "data": {
        "transcription": "cambiar menisco interno por menisco externo",
        "report_html": "<p>...full report with the correction applied...</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_html is the full report with the correction already applied: use it directly.
    • data.patch describes the exact regions that changed, if you prefer to splice them into your own editor: replace the first occurrence of target_html with refined_text, and that of conclusion_original_text with conclusion_text (when not null).
    • To chain refinements, send the returned report_html as the input of the next refine call.

    Example

    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>...last generated report...</p>" \
      --form-string "template_html=<p>...original template...</p>" \
      -F "study_id=12345"

    Highlighting AI changes (client-side diff)

    The Widget highlights what the AI changed by diffing the template against the generated report. You can do the same in your UI with jsdiff (diff on npm), with no server support:

    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('');
    }
    • Baseline: request include_template=true on generation and diff against the returned template_html.
    • After a refine, diff the previous report_html against the new one to highlight only the correction.
    • Diff the visible text (e.g. via DOMParser) rather than raw HTML strings for cleaner highlights.

    Add an addendum to a signed report

    POST https://api.omniai.com.ar/api/omni-ai/addendum
    Authorization: Bearer omp_...
    Content-Type: multipart/form-data

    The doctor has already signed the report in your system and dictates something to add to it (a missed finding, or the comparison when the patient brings prior studies). The server returns only what was dictated, as paragraphs with no title, in the center's report language. Your system adds its own heading ("ADDENDUM"), date and signature and appends it; the signed report is never changed. No template is chosen and addenda are not billed. From the Widget it is the same flow with OmniWidget.openAddendum (see Addendum on an already-signed report).

    The signed report is reference only. signed_report (signedReport in the Widget) is used only to name what the doctor refers to in the report's own terms ("the upper nodule" comes out as the report names it). It is never repeated or summarised, and no measurement or finding the doctor did not dictate is ever copied.

    Parameters

    ParameterTypeRequiredDescription
    fileFileConditionalAudio with the dictated addendum. Required unless transcription_text is sent
    transcription_textStringConditionalThe addendum as text instead of audio (max 50KB, same rules as generation: audio wins, blank = absent)
    medical_center_idStringYesA center enabled for your credential. Not needed with a Widget token, and ignored
    usernameStringRecommendedDeclared user; tracked on the addendum
    signed_reportString (HTML or text)NoThe signed report as your system holds it, manual edits included (max 100KB)
    external_study_idStringNoYour own study identifier, stored as-is, to locate the addendum in support
    user_idStringNoDoctor/user identifier for analytics
    languageStringNoAccepted for compatibility and ignored: the center's language is used

    Success response

    {
      "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: the addendum, ready to append under your heading.
    • data.transcription: the transcribed dictation (or the transcription_text you sent).
    • To correct an addendum, the doctor dictates it again: there is no refine for addenda.

    Example

    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>...signed report...</p>" \
      -F "external_study_id=ACC-00042"

    Errors

    Errors return a non-2xx status. There are three body shapes; tell them apart by the top-level key and the type of detail.

    Credential and token errors

    detail is an object. Branch on detail.code, which is stable; detail.message is human-readable and may change:

    {
      "detail": {
        "code": "center_not_enabled",
        "message": "La Partner Credential no está habilitada para el centro 'your-center'."
      }
    }
    Statusdetail.codeMeaningWhat to do
    401missing_partner_credentialPOST /api/widget/token without Authorization: Bearer omp_...Send the credential as a Bearer from your server
    401invalid_partner_credentialUnknown credential (mis-copied or rotated)Check the value; if it was rotated, use the new one
    401partner_credential_revokedCredential revoked. The Widget tokens it issued stop working in under 60 secondsAsk us for a new credential
    401widget_token_expiredWidget token expiredThe Widget renews it on its own if it has getToken
    401invalid_widget_tokenWidget token tampered with or from another serverRequest a new token with POST /api/widget/token
    403center_not_enabledYour credential is not enabled for that medical_center_idIt is configuration on our side: write to us
    503widget_token_not_configuredA problem on our side issuing or validating tokensRetry with backoff
    503partner_lookup_unavailableWe could not verify the credential right now. Not a rejectionRetry with backoff

    General errors

    Input, center and processing, with the same shape as v1 plus a timestamp:

    {
      "success": false,
      "error": "Report generation failed: ...",
      "error_code": 500,
      "timestamp": "2026-10-14T15:32:10.123456"
    }

    error_code usually repeats the HTTP status: branch on the status. The one specific code worth knowing is 1011 (the center has no templates), which comes with status 422. error is human-readable and may change.

    StatusCauseWhat to do
    400Missing input (neither file nor transcription_text); audio under 1KB; the file extension does not match its content; unsupported audio format; metadata that is not JSON or exceeds 10KB; transcription_text over 50KB; template_html or signed_report over 100KB, or template_html empty after sanitisation; blank report_html on refineFix the call; retrying it unchanged will not help
    403Disabled centerContact us
    422The center has no templates loaded (error_code 1011)Write to us to load the center's templates
    500Transcription or report generation failed, including audio with no dictation (empty transcription, e.g. a muted microphone)Check the recording; retry with backoff; if it persists, write to us with the timestamp
    502The model returned an empty addendumDictate the addendum again
    503On refine and addendum: the model is unavailable right now (error_code 503)Retry with backoff

    Validation errors

    A missing required field or one with an invalid type (e.g. medical_center_id without a Widget token, report_html on refine, or username on POST /api/widget/token) returns 422 with detail as a list, one entry per field:

    {
      "detail": [
        {"type": "missing", "loc": ["body", "medical_center_id"], "msg": "Field required"}
      ]
    }

    Limits and operations

    LimitValue
    Audio (file)Up to 100 MB; minimum 1KB
    Widget recording10 minutes
    transcription_text50KB
    metadata10KB
    template_html, signed_report100KB
    Widget token lifetime1 hour
    • Timeout: generation can take tens of seconds on long dictations. Set a client timeout of 120 seconds or more, which is the server's timeout.
    • Rate limit: there is no published per-partner limit. Retry 5xx responses with exponential backoff; do not retry 4xx responses without fixing the call.
    • Credits: each generated report consumes one credit. A call that fails consumes no credit; a successful call that is repeated consumes another. Refinements and addenda consume no credits.
    • Tokens: request one Widget token per doctor and cache it until expires_at; there is no need to request one per call.

    Health check

    To verify connectivity from your backend. No credential required:

    curl https://api.omniai.com.ar/api/health

    Response:

    {
      "status": "healthy",
      "services": {
        "api": {
          "service": "api",
          "status": "healthy",
          "available": true
        }
      }
    }

    Onboarding a partner

    1. Credential. Omnimed AI issues one Partner Credential per partner and enables it for its centers. Request it at [email protected].
    2. Centers. Omnimed AI creates each center, with its medical_center_id, and loads its templates from the template pack the center sends.
    3. Doctors. Doctors (the username values) are added in the center's admin panel. A username the center does not know still works, but only with the general templates.
    4. Testing. On request, we enable a test center so you can integrate without touching a real center's data.

    Contact: [email protected]

    Migrating from v1 to v2

    v1v2
    Direct API authenticationNone: only medical_center_idAuthorization: Bearer omp_... + medical_center_id + username
    Widget script/static/widget/v1/omni-widget.min.js/static/widget/v2/omni-widget.min.js
    Center and doctor in the Widgetdata-center-id / data-username on the pageInside the token, issued by your backend
    Widget authenticationNoneAuthorization: Bearer omw_... on every call, renewed through getToken()
    AddendaNoPOST /api/omni-ai/addendum and OmniWidget.openAddendum
    Credential errorsDo not exist{"detail": {"code", "message"}}
    Parameters, responses and reportsUnchanged

    Centers already on v1

    Centers that already called without a credential (v1, only medical_center_id) keep working that way while they migrate. New centers must use a Partner Credential. Uncredentialed access is closed center by center, with at least 90 days' notice by email.

    Steps

    1. Ask us for a Partner Credential at [email protected] and store it as an environment variable on your server.
    2. Direct API: add the Authorization: Bearer omp_... header and send username with your session's doctor on every call.
    3. Widget: add a backend endpoint that returns the token for the session's doctor, switch the script to /static/widget/v2/omni-widget.min.js, remove data-center-id / data-username from the tag (if they stay, the Widget starts in v1 mode and ignores your init) and call OmniWidget.init({ getToken, onReport }).
    4. Handle errors by detail.code (especially center_not_enabled and partner_credential_revoked).
    5. Try dictating, refining and letting an hour pass to see the token renewal.
    6. If you use addenda: your own button calling OmniWidget.openAddendum({ signedReport, externalStudyId }) and an omniai:addendum listener that appends it under your heading.

    The v1 documentation remains at /en/api-documentation/v1.

    Changelog

    v2 (2026-10-14)

    • Partner Credential (omp_...) for server-to-server calls, with username as the declared user.
    • Per-doctor Widget token (omw_...) through POST /api/widget/token, and Widget v2 at /static/widget/v2/omni-widget.min.js with getToken.
    • Addenda on already-signed reports: POST /api/omni-ai/addendum and OmniWidget.openAddendum.
    • Credential errors with a stable detail.code.
    • v1 parameters, responses and reports are unchanged.

    v1

    Support

    Questions, Partner Credentials and center onboarding: [email protected]