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 bilateralTipos de examen soportados
| exam_type | Archivos esperados | Visor | Notas |
|---|---|---|---|
| cbct | DICOM (.dcm, .dicom, .dic, .ima) | — | 3D MPR + Panorámica + Vista 3D — Recomendado modo ZIP |
| radiografia | JPEG, PNG o DICOM (1-6 imágenes) | — | Visor 2D con W/L y medición — Plan Free NO permitido (403) |
| mesh | STL o PLY (.stl, .ply) | — | Mesh Viewer (Three.js) — Escáner intraoral 3D |
| atm | DICOM (.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
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
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.
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/..."
}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:
{
"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
// 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 — 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
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
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
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
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/..."
}#!/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"// 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);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
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
# 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
POST https://cbcthub.com/api/v1/exams/{exam_id}/confirm
Authorization: Bearer cbct_live_...// 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
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
}# 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_...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
¿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ódigo | HTTP | Cuándo ocurre | Cómo resolver |
|---|---|---|---|
| type_mismatch | 400 | — | Las 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_restricted | 403 | — | Cuenta 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_exceeded | 402 | — | El 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_large | 413 | — | zip_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). |
// 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"
}
}
}