Flujo de subida

Subir un examen en 3 pasos

La API v1 soporta cuatro tipos de examen distintos. Cada tipo acepta un conjunto específico de archivos y abre un visor diferente al terminar la subida. Antes de llamar al endpoint elige el tipo correcto: la API valida las extensiones y rechaza con type_mismatch (400) si los archivos no calzan con el exam_type declarado.

Todos los tipos comparten la misma mecánica de 3 pasos (crear → PUT directo a R2 → confirmar) y los archivos viajan directo a Cloudflare R2 con URLs presignadas, sin pasar por nuestros servidores. La diferencia está en los archivos esperados, el visor que abre el share_url y, en el caso de radiografías, el plan requerido y un subtipo opcional.

Diagrama de decisión: ¿qué tipo elegir?

¿Qué tipo de examen vas a subir?
    │
    ├─ Tomografía dental (CBCT, FOV completo) ──────────► exam_type="cbct"
    │       ZIP recomendado · 200-600 DICOM · visor 3D MPR + Pano
    │
    ├─ Imagen 2D (panorámica, periapical, bitewing) ────► exam_type="radiografia"
    │       1-6 JPEG/PNG · plan Pro+ · visor 2D
    │       └─ radiograph_subtype: panoramica | teleradiografia |
    │            periapical | bitewing | periapical_total
    │
    ├─ Modelo intraoral 3D (iTero, Trios, Medit) ───────► exam_type="mesh"
    │       1-2 archivos STL/PLY · 50-200 MB c/u · Mesh Viewer 3D
    │
    └─ Estudio bilateral de la ATM ─────────────────────► exam_type="atm"
            DICOM (igual que CBCT) · visor ATM dedicado bilateral

Tipos de examen soportados

exam_typeArchivos esperadosVisorNotas
cbctDICOM (.dcm, .dicom, .dic, .ima)3D MPR + Panorámica + Vista 3D — Recomendado modo ZIP
radiografiaJPEG, PNG o DICOM (1-6 imágenes)Visor 2D con W/L y medición — Plan Free NO permitido (403)
meshSTL o PLY (.stl, .ply)Mesh Viewer (Three.js) — Escáner intraoral 3D
atmDICOM (.dcm, .dicom, .dic, .ima)Visor ATM bilateral — Análisis temporomandibular dedicado

Tipo 1 — CBCT (recomendado: modo ZIP)

Cuándo elegirlo: cualquier tomografía dental (mandíbula, maxilar, FOV completo). Un CBCT típico tiene entre 200 y 600 archivos DICOM con un peso total de 80-500 MB. Por la cantidad de archivos te recomendamos usar upload_mode="zip": una sola llamada PUT y un endpoint /process-zip que descomprime en streaming server-side, sube cada DICOM a R2 y marca el examen como ready en una sola operación.

Al terminar, share_url y viewer_url abren el visor CBCT completo: MPR axial/sagital/coronal, reconstrucción panorámica, cortes oblicuos, Vista 3D, herramientas de medición, planificación de implantes y trazado del nervio dentario inferior.

Paso 1 — Crear el examen en modo ZIP

bash
POST https://cbcthub.com/api/v1/exams
Authorization: Bearer cbct_live_...
Content-Type: application/json

{
  "name": "CBCT mandíbula",
  "exam_type": "cbct",
  "patient_name": "Juan Pérez",
  "patient_id": "12.345.678-9",
  "birth_date": "1985-03-22",
  "reason": "Planificación de implantes",
  "expiration_days": 365,
  "upload_mode": "zip",
  "zip_size_bytes": 158234567
}

→ 201 {
  "exam_id": "8b1c0d2e-7f31-4a99-9b3c-1f6e7a3f2d11",
  "status": "uploading",
  "exam_type": "cbct",
  "upload_mode": "zip",
  "upload_url": "https://....r2.cloudflarestorage.com/staging/.../upload.zip?...",
  "upload_url_method": "PUT",
  "upload_url_content_type": "application/zip",
  "process_url": "https://cbcthub.com/api/v1/exams/8b1c.../process-zip",
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}

Paso 2 — Subir el ZIP con un único PUT

bash
PUT https://....r2.cloudflarestorage.com/staging/.../upload.zip
Content-Type: application/zip

<bytes of the entire ZIP — one HTTP request, no auth header>

Paso 3 — Disparar el procesamiento server-side

El servidor descomprime el ZIP en streaming (sin cargarlo entero a memoria), sube cada DICOM a R2, ajusta storage_used_bytes y marca el examen como ready. Justo después se dispara el webhook exam.confirmed con el conteo final de archivos.

bash
POST https://cbcthub.com/api/v1/exams/{exam_id}/process-zip
Authorization: Bearer cbct_live_...

→ 200 {
  "ok": true,
  "exam_id": "8b1c0d2e-...",
  "status": "ready",
  "files_extracted": 412,
  "storage_bytes": 158110000,
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}
Límites por ZIP: 1 GB de tamaño total, 5000 archivos dentro, 5 GB expandidos, 1,6 TB por archivo individual extraído (cubre cualquier DICOM multiframe), 300 s de timeout para descomprimir. Errores específicos: zip_too_large (413), invalid_zip (400 — corrupto o sin staging), no_dicom_files (400 — ZIP sin DICOMs), too_many_files / too_large_extracted (413), file_too_large (archivo individual sobre el tope).

Detección automática de tomas múltiples

Si tu ZIP contiene 2 o más estudios CBCT distintos (típicamente, dos carpetas adentro, una por tomografía), la API los detecta automáticamente leyendo el SeriesInstanceUID de cada DICOM y los agrupa: la serie con más cortes queda como toma principal y las siguientes como tomas extras (máximo 3 tomas totales = principal + 2 extras, igual que en el panel). El visor mostrará al receptor un selector de toma. Si tu ZIP es siempre un solo estudio puedes enviar { "auto_split_series": false } en el body de /process-zip para saltarte el parseo (ahorra entre 3 y 10 s por GB).

Cuando se detecta multi-toma, la respuesta de /process-zip incluye campos extra:

json
{
  "ok": true,
  "exam_id": "...",
  "status": "ready",
  "files_extracted": 724,
  "storage_bytes": 280450000,
  "auto_split_series": true,
  "extras_count": 1,
  "series_detected": [
    { "index": 1, "label": "Serie principal", "file_count": 412, "storage_bytes": 158110000, "role": "principal" },
    { "index": 2, "label": "Toma 2",          "file_count": 312, "storage_bytes": 122340000, "role": "extra" }
  ],
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}

Ejemplo end-to-end en Node.js

javascript
// Node.js — CBCT en modo ZIP (recomendado)
import { readFile, stat } from 'node:fs/promises';

const KEY = process.env.CBCTHUB_KEY;
const zipPath = 'cbct_mandibula.zip';
const zipBuffer = await readFile(zipPath);
const { size } = await stat(zipPath);

// 1) Crear el examen (modo ZIP)
const create = await fetch('https://cbcthub.com/api/v1/exams', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'CBCT mandíbula',
    exam_type: 'cbct',
    patient_name: 'Juan Pérez',
    upload_mode: 'zip',
    zip_size_bytes: size,
  }),
}).then((r) => r.json());

// 2) PUT directo a R2 (la URL ya está firmada)
await fetch(create.upload_url, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/zip' },
  body: zipBuffer,
});

// 3) Disparar descompresión server-side
const ready = await fetch(create.process_url, {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());

console.log('Archivos extraídos:', ready.files_extracted);
console.log('Visor:', ready.viewer_url);
// El webhook exam.confirmed llega a tu endpoint con el mismo payload.
python
# Python — CBCT en modo ZIP
import os, requests

KEY = os.environ['CBCTHUB_KEY']
zip_path = 'cbct_mandibula.zip'
zip_size = os.path.getsize(zip_path)

create = requests.post(
    'https://cbcthub.com/api/v1/exams',
    headers={'Authorization': f'Bearer {KEY}'},
    json={
        'name': 'CBCT mandíbula',
        'exam_type': 'cbct',
        'patient_name': 'Juan Pérez',
        'upload_mode': 'zip',
        'zip_size_bytes': zip_size,
    },
).json()

with open(zip_path, 'rb') as f:
    requests.put(
        create['upload_url'],
        headers={'Content-Type': 'application/zip'},
        data=f,
    ).raise_for_status()

ready = requests.post(
    create['process_url'],
    headers={'Authorization': f'Bearer {KEY}'},
).json()

print(f"Extracted: {ready['files_extracted']} files")
print(f"Viewer: {ready['viewer_url']}")

Tipo 2 — Radiografía 2D

Cuándo elegirlo: imágenes 2D dentales como panorámicas, periapicales, bitewings, teleradiografías o cefalometrías laterales. Lo más común es entre 1 y 6 archivos JPEG o PNG (también se acepta DICOM 2D). El campo opcional radiograph_subtype categoriza la imagen para que el visor muestre el tooling correcto.

Plan Free no permitido

El tipo radiografia requiere plan Pro o superior. Una cuenta Free recibe 403 plan_restricted al intentar crear el examen. Si tu integración debe servir centros con plan Free, valida el plan antes de mostrar la opción.

Valores válidos para radiograph_subtype (todos opcionales):

  • panoramica — radiografía panorámica clásica.
  • teleradiografia — teleradiografía / cefalometría lateral.
  • periapical — radiografía periapical aislada.
  • bitewing — bitewing (interproximal).
  • periapical_total — serie periapical completa (status radiográfico).

Paso 1 — Crear el examen

bash
POST https://cbcthub.com/api/v1/exams
Authorization: Bearer cbct_live_...
Content-Type: application/json

{
  "name": "Panorámica preoperatoria",
  "exam_type": "radiografia",
  "radiograph_subtype": "panoramica",
  "patient_name": "María González",
  "upload_mode": "files",
  "files": [
    { "name": "panoramica.jpg", "size": 1842560 }
  ]
}

→ 201 {
  "exam_id": "f02a...",
  "status": "uploading",
  "exam_type": "radiografia",
  "radiograph_subtype": "panoramica",
  "upload_urls": [
    {
      "name": "panoramica.jpg",
      "url": "https://....r2.cloudflarestorage.com/...?...",
      "method": "PUT",
      "content_type": "image/jpeg"
    }
  ],
  "confirm_url": "https://cbcthub.com/api/v1/exams/f02a.../confirm",
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}

Paso 2 — Subir cada imagen con PUT

bash
PUT https://....r2.cloudflarestorage.com/.../panoramica.jpg
Content-Type: image/jpeg

<bytes of the JPEG — no auth header, URL is already presigned>

Paso 3 — Confirmar

bash
POST https://cbcthub.com/api/v1/exams/{exam_id}/confirm
Authorization: Bearer cbct_live_...

→ 200 {
  "ok": true,
  "exam_id": "f02a...",
  "status": "ready",
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}
bash
#!/bin/bash
# End-to-end con curl + jq — radiografía panorámica
set -euo pipefail

KEY="$CBCTHUB_KEY"
FILE="panoramica.jpg"
SIZE=$(stat -f%z "$FILE" 2>/dev/null || stat -c%s "$FILE")

# 1) Crear examen
EXAM=$(curl -sS https://cbcthub.com/api/v1/exams \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"Panorámica preoperatoria\",
    \"exam_type\": \"radiografia\",
    \"radiograph_subtype\": \"panoramica\",
    \"patient_name\": \"María González\",
    \"upload_mode\": \"files\",
    \"files\": [{ \"name\": \"$FILE\", \"size\": $SIZE }]
  }")

UPLOAD_URL=$(echo "$EXAM" | jq -r '.upload_urls[0].url')
CONFIRM_URL=$(echo "$EXAM" | jq -r '.confirm_url')

# 2) PUT al presigned URL (sin Authorization)
curl -sS -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @"$FILE"

# 3) Confirmar
curl -sS -X POST "$CONFIRM_URL" \
  -H "Authorization: Bearer $KEY"
javascript
// Node.js — radiografía panorámica end-to-end
import { readFile, stat } from 'node:fs/promises';

const KEY = process.env.CBCTHUB_KEY;
const path = 'panoramica.jpg';
const buf = await readFile(path);
const { size } = await stat(path);

const create = await fetch('https://cbcthub.com/api/v1/exams', {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Panorámica preoperatoria',
    exam_type: 'radiografia',
    radiograph_subtype: 'panoramica',
    patient_name: 'María González',
    upload_mode: 'files',
    files: [{ name: 'panoramica.jpg', size }],
  }),
}).then((r) => r.json());

const presigned = create.upload_urls[0];
await fetch(presigned.url, {
  method: 'PUT',
  headers: { 'Content-Type': presigned.content_type },
  body: buf,
});

const ready = await fetch(create.confirm_url, {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());

console.log('Share:', ready.share_url);
Tip de compatibilidad: convierte las imágenes a JPEG antes de subirlas. JPEG carga más rápido en el visor 2D, se comprime mejor para el envío por email y es lo que los clientes esperan recibir como adjunto. Reserva el DICOM 2D para casos donde necesites preservar metadata del equipo.

Tipo 3 — STL/PLY de escáner intraoral

Cuándo elegirlo: modelos 3D exportados por escáneres intraorales como iTero, Trios, Medit, CEREC Primescan o Carestream. Lo típico son 1 o 2 archivos (upper.stl + lower.stl) de 50 a 200 MB cada uno. También se aceptan archivos PLY de programas de planificación.

El visor abre un Mesh Viewer basado en Three.js: rotación 3D libre, fondo configurable, herramientas de medición sobre la malla, captura de pantalla y comparación entre maxilar y mandíbula.

Paso 1 — Crear el examen

bash
POST https://cbcthub.com/api/v1/exams
Authorization: Bearer cbct_live_...
Content-Type: application/json

{
  "name": "Escaneo intraoral - control 3 meses",
  "exam_type": "mesh",
  "patient_name": "Ana Soto",
  "upload_mode": "files",
  "files": [
    { "name": "upper.stl", "size": 87234560 },
    { "name": "lower.stl", "size": 91120384 }
  ]
}

→ 201 {
  "exam_id": "3d11...",
  "status": "uploading",
  "exam_type": "mesh",
  "upload_urls": [
    { "name": "upper.stl", "url": "https://....r2.cloudflarestorage.com/...?...", "method": "PUT", "content_type": "model/stl" },
    { "name": "lower.stl", "url": "https://....r2.cloudflarestorage.com/...?...", "method": "PUT", "content_type": "model/stl" }
  ],
  "confirm_url": "https://cbcthub.com/api/v1/exams/3d11.../confirm",
  "share_url": "https://cbcthub.com/share/...",
  "viewer_url": "https://cbcthub.com/viewer/..."
}

Paso 2 — Subir cada STL con PUT

bash
# Un PUT por archivo, en paralelo o en serie
PUT https://....r2.cloudflarestorage.com/.../upper.stl
Content-Type: model/stl
<bytes>

PUT https://....r2.cloudflarestorage.com/.../lower.stl
Content-Type: model/stl
<bytes>

Paso 3 — Confirmar

bash
POST https://cbcthub.com/api/v1/exams/{exam_id}/confirm
Authorization: Bearer cbct_live_...
javascript
// Node.js — STL bimaxilar (upper + lower) end-to-end
import { readFile, stat } from 'node:fs/promises';

const KEY = process.env.CBCTHUB_KEY;
const paths = ['upper.stl', 'lower.stl'];
const files = await Promise.all(
  paths.map(async (p) => ({ path: p, buf: await readFile(p), size: (await stat(p)).size })),
);

const create = await fetch('https://cbcthub.com/api/v1/exams', {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Escaneo intraoral',
    exam_type: 'mesh',
    patient_name: 'Ana Soto',
    upload_mode: 'files',
    files: files.map((f) => ({ name: f.path, size: f.size })),
  }),
}).then((r) => r.json());

// PUT cada STL en paralelo
await Promise.all(
  create.upload_urls.map((u, i) =>
    fetch(u.url, {
      method: 'PUT',
      headers: { 'Content-Type': u.content_type },
      body: files[i].buf,
    }),
  ),
);

const ready = await fetch(create.confirm_url, {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());

console.log('Mesh Viewer:', ready.viewer_url);

Tipo 4 — ATM (Temporomandibular)

Cuándo elegirlo: estudios bilaterales de la articulación temporomandibular. Los archivos son DICOM, exactamente como CBCT, así que el flujo de subida es idéntico (recomendado modo ZIP). La única diferencia es que con exam_type="atm" el share_url abre el visor ATM dedicado, con análisis bilateral lado derecho / lado izquierdo, MIP slab sagital y herramientas de orientación y crop específicas para la articulación.

Paso 1 — Crear el examen en modo ZIP

bash
POST https://cbcthub.com/api/v1/exams
Authorization: Bearer cbct_live_...
Content-Type: application/json

{
  "name": "ATM bilateral - paciente Pérez",
  "exam_type": "atm",
  "patient_name": "Carlos Pérez",
  "reason": "Disfunción temporomandibular bilateral",
  "upload_mode": "zip",
  "zip_size_bytes": 92480123
}
bash
# 2) PUT del ZIP a R2 (sin Authorization)
PUT https://....r2.cloudflarestorage.com/staging/.../upload.zip
Content-Type: application/zip

# 3) Procesar
POST https://cbcthub.com/api/v1/exams/{exam_id}/process-zip
Authorization: Bearer cbct_live_...
Si necesitas comparar ambos lados con tomas separadas (estudio dual), sube primero el lado derecho como examen ATM y luego añade el segundo volumen desde el dashboard o desde la API de extras del visor. El visor ATM permite asignar manualmente cada toma al lado correcto.

Mecánica común a los 4 tipos: el flujo de 3 pasos

Sin importar el tipo de examen, la API expone siempre la misma mecánica de tres pasos. Los archivos viajan directo a Cloudflare R2 con URLs presignadas (no pasan por nuestros servidores) y la confirmación final emite los eventos exam.confirmed o exam.created según corresponda.

Diagrama del flujo

TU SISTEMA                CBCTHub API             Cloudflare R2
    │                         │                       │
    ├──POST /v1/exams────────►│                       │
    │   exam_type + files     │                       │
    │                         │                       │
    │◄────exam_id, upload_urls┤                       │
    │                                                 │
    ├──PUT file_1 ────────────────────────────────────►│
    ├──PUT file_2 ────────────────────────────────────►│
    │◄───── 200 OK ───────────────────────────────────┤
    │                                                 │
    ├──POST /v1/exams/{id}/confirm ►│                 │
    │   (o /process-zip en modo ZIP)                  │
    │◄────share_url, viewer_url─────┤                 │
    │                               │                 │
    │◄──── webhook exam.confirmed ──┤                 │

Idempotencia

/confirm y /process-zip son idempotentes: llamarlos varias veces no duplica el examen. Si se cae tu conexión justo después de un confirm exitoso, puedes reintentar sin riesgo de cobrar storage dos veces.

¿Qué pasa si tardo más de 15 minutos en subir?

Las URLs presignadas expiran a los 15 minutos. Si tu cliente no terminó la subida en ese tiempo, vuelve a llamar POST /api/v1/exams para regenerar las URLs. El exam_id viejo queda en status uploading hasta que lo elimines con DELETE /api/v1/exams/{id}.

Errores comunes al crear exámenes

Estos son los códigos que más vas a ver en producción. Todos llegan como JSON con la forma { "error": { "code": "...", "message": "..." } } y el HTTP status indicado.

CódigoHTTPCuándo ocurreCómo resolver
type_mismatch400Las extensiones de files[] no calzan con exam_type (ej. .stl con exam_type="cbct"). Revisa el exam_type declarado y la lista de extensiones permitidas para ese tipo (tabla más arriba).
plan_restricted403Cuenta Free intentando crear exam_type="radiografia". Sugiere upgrade al plan Pro o superior. Otros tipos (cbct, mesh, atm) sí están disponibles en Free dentro del cupo de storage.
quota_exceeded402El examen no entra en el storage disponible de la cuenta. available_bytes y requested_bytes vienen en el campo extra. Pide al cliente liberar espacio o subir de plan.
zip_too_large413zip_size_bytes (modo zip) supera 1 GB. Divide el estudio en exámenes separados o usa upload_mode="files" para subir DICOM directos (cada archivo individual puede ser muy grande).
json
// type_mismatch (400) — ejemplo de respuesta real
{
  "error": {
    "code": "type_mismatch",
    "message": "File \"upper.stl\" does not match exam_type \"cbct\". Allowed: .dcm, .dicom, .dic, .ima (DICOM).",
    "extra": {
      "exam_type": "cbct",
      "allowed": ".dcm, .dicom, .dic, .ima (DICOM)",
      "offending_file": "upper.stl"
    }
  }
}