Fluxo de envio
Enviar um exame em 3 passos
A API v1 suporta quatro tipos de exame distintos. Cada tipo aceita um conjunto específico de arquivos e abre um visualizador diferente ao terminar o envio. Escolha o tipo certo antes de chamar o endpoint: a API valida as extensões e rejeita com type_mismatch (400) quando os arquivos não correspondem ao exam_type declarado.
Todos os tipos compartilham a mesma mecânica de 3 passos (criar → PUT direto ao R2 → confirmar) e os arquivos viajam direto ao Cloudflare R2 com URLs pré-assinadas, sem passar pelos nossos servidores. O que muda entre tipos é o formato dos arquivos esperados, o visualizador que abre a share_url e (no caso de radiografias) o plano exigido e um subtipo opcional.
Diagrama de decisão: qual tipo escolher?
Qual tipo de exame você vai enviar?
│
├─ Tomografia dentária (CBCT, FOV completo) ────────► exam_type="cbct"
│ ZIP recomendado · 200-600 DICOM · visualizador 3D MPR + Pano
│
├─ Imagem 2D (panorâmica, periapical, bitewing) ────► exam_type="radiografia"
│ 1-6 JPEG/PNG · plano Pro+ · visualizador 2D
│ └─ radiograph_subtype: panoramica | teleradiografia |
│ periapical | bitewing | periapical_total
│
├─ Escaneamento intraoral 3D (iTero, Trios, Medit) ─► exam_type="mesh"
│ 1-2 arquivos STL/PLY · 50-200 MB cada · Mesh Viewer 3D
│
└─ Estudo bilateral da ATM ─────────────────────────► exam_type="atm"
DICOM (igual ao CBCT) · visualizador ATM bilateral dedicadoTipos de exame suportados
| exam_type | Arquivos esperados | Visualizador | Notas |
|---|---|---|---|
| cbct | DICOM (.dcm, .dicom, .dic, .ima) | — | 3D MPR + Panorâmica + Visão 3D — Modo ZIP recomendado |
| radiografia | JPEG, PNG ou DICOM (1-6 imagens) | — | Visualizador 2D com W/L e medição — Plano Free NÃO permitido (403) |
| mesh | STL ou PLY (.stl, .ply) | — | Mesh Viewer (Three.js) — Scanner intraoral 3D |
| atm | DICOM (.dcm, .dicom, .dic, .ima) | — | Visualizador ATM bilateral — Análise temporomandibular dedicada |
Tipo 1 — CBCT (recomendado: modo ZIP)
Quando escolher: qualquer tomografia dentária (mandíbula, maxila, FOV completo). Um CBCT típico tem entre 200 e 600 arquivos DICOM com peso total de 80-500 MB. Pela quantidade de arquivos recomendamos upload_mode="zip": uma única chamada PUT e o endpoint /process-zip que descompacta em streaming no servidor, envia cada DICOM ao R2 e marca o exame como ready em uma única operação.
Ao terminar, share_url e viewer_url abrem o visualizador CBCT completo: MPR axial/sagital/coronal, reconstrução panorâmica, cortes oblíquos, Visão 3D, ferramentas de medição, planejamento de implantes e traçado do nervo alveolar inferior.
Passo 1 — Criar o exame em 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/..."
}Passo 2 — Enviar o ZIP com um ú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>Passo 3 — Disparar o processamento no servidor
O servidor descompacta o ZIP em streaming (sem carregá-lo inteiro em memória), envia cada DICOM ao R2, ajusta storage_used_bytes e marca o exame como ready. Logo depois, o webhook exam.confirmed dispara com a contagem final de arquivos.
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/..."
}Detecção automática de tomadas múltiplas
Se o seu ZIP contém 2 ou mais estudos CBCT distintos (tipicamente, duas pastas dentro, uma por tomografia), a API os detecta automaticamente lendo o SeriesInstanceUID de cada DICOM e os agrupa: a série com mais cortes vira a tomada principal e as seguintes viram tomadas extras (máximo 3 tomadas no total = principal + 2 extras, igual ao painel). O visualizador exibirá ao receptor um seletor de tomada. Se o seu ZIP é sempre um único estudo, envie { "auto_split_series": false } no corpo de /process-zip para pular a análise (economiza 3-10 s por GB).
Quando multi-tomada é detectada, a resposta de /process-zip inclui campos extras:
{
"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/..."
}Exemplo end-to-end em 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 — Radiografia 2D
Quando escolher: imagens 2D dentárias como panorâmicas, periapicais, bitewings, telerradiografias ou cefalometrias laterais. O mais comum são 1 a 6 arquivos JPEG ou PNG (DICOM 2D também é aceito). O campo opcional radiograph_subtype rotula a imagem para o visualizador exibir as ferramentas certas.
⚠ Plano Free não permitido
Valores válidos para radiograph_subtype (todos opcionais):
- panoramica — radiografia panorâmica clássica.
- teleradiografia — telerradiografia / cefalometria lateral.
- periapical — radiografia periapical isolada.
- bitewing — bitewing (interproximal).
- periapical_total — série periapical completa (status radiográfico).
Passo 1 — Criar o exame
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/..."
}Passo 2 — Enviar cada imagem com PUT
PUT https://....r2.cloudflarestorage.com/.../panoramica.jpg
Content-Type: image/jpeg
<bytes of the JPEG — no auth header, URL is already presigned>Passo 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 scanner intraoral
Quando escolher: modelos 3D exportados por scanners intraorais como iTero, Trios, Medit, CEREC Primescan ou Carestream. O típico são 1 ou 2 arquivos (upper.stl + lower.stl) de 50 a 200 MB cada. Arquivos PLY de softwares de planejamento também são aceitos.
O visualizador abre um Mesh Viewer baseado em Three.js: rotação 3D livre, fundo configurável, medições sobre a malha, captura de tela e comparação entre maxila e mandíbula.
Passo 1 — Criar o exame
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/..."
}Passo 2 — Enviar cada STL com 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>Passo 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)
Quando escolher: estudos bilaterais da articulação temporomandibular. Os arquivos são DICOM, exatamente como CBCT, então o fluxo de envio é idêntico (modo ZIP recomendado). A única diferença é que com exam_type="atm" a share_url abre o visualizador ATM dedicado, com análise bilateral lado direito / lado esquerdo, MIP slab sagital e ferramentas de orientação e crop específicas para a articulação.
Passo 1 — Criar o exame em 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 comum aos 4 tipos: o fluxo de 3 passos
Independente do tipo de exame, a API expõe sempre a mesma mecânica de três passos. Os arquivos vão direto ao Cloudflare R2 com URLs pré-assinadas (não passam pelos nossos servidores) e a confirmação final emite os eventos exam.confirmed ou exam.created conforme o caso.
Diagrama do fluxo
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 ──┤ │ℹ Idempotência
E se o envio demorar mais de 15 minutos?
As URLs pré-assinadas expiram em 15 minutos. Se seu cliente não terminou nesse prazo, chame POST /api/v1/exams novamente para regerar as URLs. O exam_id antigo permanece em status uploading até que você o exclua com DELETE /api/v1/exams/{id}.
Erros comuns ao criar exames
Estes são os códigos que você verá com mais frequência em produção. Todos chegam como JSON no formato { "error": { "code": "...", "message": "..." } } com o status HTTP indicado.
| Código | HTTP | Quando ocorre | Como resolver |
|---|---|---|---|
| type_mismatch | 400 | — | As extensões em files[] não correspondem ao exam_type (ex.: .stl com exam_type="cbct"). Verifique o exam_type declarado e a lista de extensões permitidas para esse tipo (tabela acima). |
| plan_restricted | 403 | — | Conta Free tentando criar exam_type="radiografia". Sugira upgrade ao plano Pro ou superior. Outros tipos (cbct, mesh, atm) estão disponíveis no Free dentro da cota de armazenamento. |
| quota_exceeded | 402 | — | O exame não cabe no armazenamento disponível da conta. available_bytes e requested_bytes vêm no campo extra. Peça ao cliente que libere espaço ou faça upgrade do plano. |
| zip_too_large | 413 | — | zip_size_bytes (modo zip) excede 1 GB. Divida o estudo em exames separados ou use upload_mode="files" para enviar DICOMs diretos (cada arquivo individual pode ser muito 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"
}
}
}