# Omnimed AI Integration Guide (v2)

## Quick start

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

1. **Request your Partner Credential** from omni@omniai.com.ar. It is an `omp_...` secret shown only once: store it as an environment variable on your server. See [Onboarding a partner](#onboarding-a-partner).
2. **Make the first call.** From your server, with a test recording:

   ```bash
   curl -X POST https://api.omniai.com.ar/api/omni-ai \
     -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
     -F "file=@dictation.wav" \
     -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](#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](#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](https://www.omniai.com.ar/docs/widget-token-flow.en.png)

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:

| `username` | Templates the choice is made from |
|------------|-----------------------------------|
| absent (direct API only) | The center's general templates only |
| a user of the center | The general ones plus those assigned to that doctor (directly or through a group) |
| a user the center does not know | General 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
```

| Where | Field | Required | Description |
|-------|-------|----------|-------------|
| Header | `Authorization: Bearer omp_...` | Yes | Your Partner Credential. Only on your server |
| JSON body | medical_center_id | Yes | The center the Widget acts for (the same ID as in v1). Must be enabled for your credential |
| JSON body | username | Yes | The doctor using the Widget, by their username in the center (declared user). A token is per doctor |
| JSON body | user_id | No | Your own doctor ID; only recorded |

`200` response:

```json
{
  "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**

```bash
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+).

```javascript
// 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

```html
<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` option | Description |
|---------------|-------------|
| getToken | Function that returns (or resolves to) the `omw_...` token as text. Always recommended |
| token | Initial token, optional |
| apiUrl | API base URL. Defaults to the origin the script was loaded from (`https://api.omniai.com.ar`) |
| onReport | Callback with the report, same as the `omniai:report` event |
| onAudio | Callback with the raw recording, same as the `omniai:audio` event |
| onAddendum | Callback with the addendum, same as the `omniai:addendum` event |
| addendumButton | `false` 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:

```javascript
// 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`:

```javascript
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
});
```

| Field | Description |
|-------|-------------|
| blob | The recording (`Blob`), usually WebM/Opus (Ogg or MP4 in browsers without WebM) |
| mimeType | MIME type of the blob |
| durationSeconds | Length of the recording |
| sizeBytes | Size of the blob |
| kind | `"dictation"`, `"refinement"` (a spoken correction) or `"addendum"` |
| recordingId | Unique ID of this recording. The `omniai:report` and `omniai:addendum` payloads carry the same `recordingId`, linking audio and result |
| parentRecordingId | On a refinement, the `recordingId` of the dictation it corrects; `null` otherwise |
| recordedAt | ISO 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.

```html
<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](#add-an-addendum-to-a-signed-report)).
- `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` field | Description |
|-------------------------|-------------|
| html | The addendum as HTML (paragraphs, no title) |
| text | The same addendum as plain text |
| transcription | The transcribed dictation |
| externalStudyId | The one you passed to `openAddendum`, or `null` |
| recordingId | The same as the `omniai:audio` of that recording (which arrives with `kind: "addendum"`) |
| eventId | Event 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

| State | Meaning |
|-------|---------|
| idle | Ready to record |
| recording | Microphone active |
| paused | Recording paused |
| processing | Audio uploaded, generating report |
| result | Report visible in the panel |
| error | Processing 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

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| file | File | Conditional | Audio file (wav, mp3, m4a, ogg, flac, webm), up to 100 MB. Required unless `transcription_text` is sent |
| transcription_text | String | Conditional | Dictation text used instead of audio (see below). Required unless `file` is sent |
| medical_center_id | String | Yes | A center enabled for your credential. Not needed with a Widget token, and ignored |
| username | String | Recommended | Declared 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_id | String | No | Doctor/user identifier for analytics |
| language | String | No | Does not change the language: dictation and report use the center's language. Only echoed back as `language_detected` when the input is `transcription_text` |
| metadata | String (JSON) | No | Free-form JSON metadata, max 10KB |
| dicom_metadata | String (JSON) | No | The ordered study, from the order / DICOM headers; triggers the mismatch warnings (see below) |
| template_html | String (HTML) | No | Preselected template: generate the report against exactly this template (see below) |
| template_name | String | No | Label of the preselected template; echoed back as `study_name` / `template_used` |
| include_template | Boolean | No | Return the HTML of the template the server used, for diff highlighting |
| include_rtf | Boolean | No | Return 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:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@recording.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "username=dr.martinez"
```

**cURL (preselected template)**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@recording.m4a" \
  -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**

```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)**

```javascript
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

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| file | File | Conditional | Audio with the dictated correction. Required unless `transcription_text` is sent |
| transcription_text | String | Conditional | The correction as text instead of audio (max 50KB, same rules as generation: audio wins, blank = absent) |
| medical_center_id | String | Yes | A center enabled for your credential. Not needed with a Widget token, and ignored |
| report_html | String (HTML) | Yes | The last generated report, exactly as the API returned it (or as it stood after the last refinement) |
| template_html | String (HTML) | No | The report's original template; used as a read-only style/format reference (max 100KB) |
| template_name | String | No | Template/study-type name, enables per-study-type rules |
| selected_html | String (HTML) | No | Exact HTML element to refine; omit it to let the model locate the target from the dictation |
| study_id | Integer | No | The `metadata.study_id` from the generation call, to trace the refinement |
| user_id | String | No | Doctor/user identifier for analytics |
| language | String | No | Accepted for compatibility and ignored: the center's language is used |

#### Success response

```json
{
  "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

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai/refine \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@correction.wav" \
  -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](https://github.com/kpdecker/jsdiff) (`diff` on npm), with no server support:

```javascript
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](#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

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| file | File | Conditional | Audio with the dictated addendum. Required unless `transcription_text` is sent |
| transcription_text | String | Conditional | The addendum as text instead of audio (max 50KB, same rules as generation: audio wins, blank = absent) |
| medical_center_id | String | Yes | A center enabled for your credential. Not needed with a Widget token, and ignored |
| username | String | Recommended | Declared user; tracked on the addendum |
| signed_report | String (HTML or text) | No | The signed report as your system holds it, manual edits included (max 100KB) |
| external_study_id | String | No | Your own study identifier, stored as-is, to locate the addendum in support |
| user_id | String | No | Doctor/user identifier for analytics |
| language | String | No | Accepted for compatibility and ignored: the center's language is used |

#### Success response

```json
{
  "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

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai/addendum \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@addendum.wav" \
  -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:

```json
{
  "detail": {
    "code": "center_not_enabled",
    "message": "La Partner Credential no está habilitada para el centro 'your-center'."
  }
}
```

| Status | `detail.code` | Meaning | What to do |
|--------|---------------|---------|------------|
| 401 | `missing_partner_credential` | `POST /api/widget/token` without `Authorization: Bearer omp_...` | Send the credential as a Bearer from your server |
| 401 | `invalid_partner_credential` | Unknown credential (mis-copied or rotated) | Check the value; if it was rotated, use the new one |
| 401 | `partner_credential_revoked` | Credential revoked. The Widget tokens it issued stop working in under 60 seconds | Ask us for a new credential |
| 401 | `widget_token_expired` | Widget token expired | The Widget renews it on its own if it has `getToken` |
| 401 | `invalid_widget_token` | Widget token tampered with or from another server | Request a new token with `POST /api/widget/token` |
| 403 | `center_not_enabled` | Your credential is not enabled for that `medical_center_id` | It is configuration on our side: write to us |
| 503 | `widget_token_not_configured` | A problem on our side issuing or validating tokens | Retry with backoff |
| 503 | `partner_lookup_unavailable` | We could not verify the credential right now. Not a rejection | Retry with backoff |

### General errors

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

```json
{
  "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.

| Status | Cause | What to do |
|--------|-------|------------|
| 400 | Missing 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 refine | Fix the call; retrying it unchanged will not help |
| 403 | Disabled center | Contact us |
| 422 | The center has no templates loaded (`error_code` 1011) | Write to us to load the center's templates |
| 500 | Transcription 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` |
| 502 | The model returned an empty addendum | Dictate the addendum again |
| 503 | On 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:

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

## Limits and operations

| Limit | Value |
|-------|-------|
| Audio (`file`) | Up to 100 MB; minimum 1KB |
| Widget recording | 10 minutes |
| `transcription_text` | 50KB |
| `metadata` | 10KB |
| `template_html`, `signed_report` | 100KB |
| Widget token lifetime | 1 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:

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

Response:

```json
{
  "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 omni@omniai.com.ar.
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: omni@omniai.com.ar

## Migrating from v1 to v2

| | v1 | v2 |
|--|----|----|
| Direct API authentication | None: only `medical_center_id` | `Authorization: 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 Widget | `data-center-id` / `data-username` on the page | Inside the token, issued by your backend |
| Widget authentication | None | `Authorization: Bearer omw_...` on every call, renewed through `getToken()` |
| Addenda | No | `POST /api/omni-ai/addendum` and `OmniWidget.openAddendum` |
| Credential errors | Do not exist | `{"detail": {"code", "message"}}` |
| Parameters, responses and reports | | Unchanged |

### 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 omni@omniai.com.ar 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](https://www.omniai.com.ar/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

- Widget v1 and direct API authenticated by `medical_center_id` only. See [/en/api-documentation/v1](https://www.omniai.com.ar/en/api-documentation/v1).

## Support

Questions, Partner Credentials and center onboarding: omni@omniai.com.ar

---

Source: https://www.omniai.com.ar/en/api-documentation/v2
Site index for agents: https://www.omniai.com.ar/llms.txt
Sitemap: https://www.omniai.com.ar/sitemap.xml
OpenAPI: https://www.omniai.com.ar/openapi.json
OpenAPI v2: https://www.omniai.com.ar/openapi-v2.json
