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 dedicado

Tipos de exame suportados

exam_typeArquivos esperadosVisualizadorNotas
cbctDICOM (.dcm, .dicom, .dic, .ima)3D MPR + Panorâmica + Visão 3D — Modo ZIP recomendado
radiografiaJPEG, PNG ou DICOM (1-6 imagens)Visualizador 2D com W/L e medição — Plano Free NÃO permitido (403)
meshSTL ou PLY (.stl, .ply)Mesh Viewer (Three.js) — Scanner intraoral 3D
atmDICOM (.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

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/..."
}

Passo 2 — Enviar o ZIP com um ú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>

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.

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/..."
}
Limites por ZIP: 1 GB de tamanho total, 5000 arquivos dentro, 5 GB expandidos, 1,6 TB por arquivo individual extraído (cobre qualquer DICOM multiframe), 300 s de timeout para descompactar. Erros específicos: zip_too_large (413), invalid_zip (400 — corrompido ou sem staging), no_dicom_files (400 — ZIP sem DICOMs), too_many_files / too_large_extracted (413), file_too_large (arquivo individual acima do teto).

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:

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/..."
}

Exemplo end-to-end em 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 — 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

O tipo radiografia exige plano Pro ou superior. Uma conta Free recebe 403 plan_restricted ao tentar criar o exame. Se sua integração pode atender centros Free, valide o plano antes de expor a opção.

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

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/..."
}

Passo 2 — Enviar cada imagem com PUT

bash
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

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);
Dica de compatibilidade: converta as imagens para JPEG antes do envio. JPEG carrega mais rápido no visualizador 2D, comprime melhor para envio por e-mail e é o formato esperado como anexo. Reserve o DICOM 2D para casos em que precise preservar metadados do equipamento.

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

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/..."
}

Passo 2 — Enviar cada STL com 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>

Passo 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)

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

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_...
Se precisar comparar ambos os lados com capturas separadas (estudo dual), envie primeiro o lado direito como exame ATM e depois adicione o segundo volume pelo dashboard ou pela API de extras do visualizador. O visualizador ATM permite atribuir manualmente cada captura ao lado correto.

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

/confirm e /process-zip são idempotentes: chamá-los várias vezes não duplica o exame. Se a sua conexão cair logo após um confirm bem-sucedido, você pode reintentar sem risco de cobrar armazenamento duas vezes.

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ódigoHTTPQuando ocorreComo resolver
type_mismatch400As 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_restricted403Conta 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_exceeded402O 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_large413zip_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).
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"
    }
  }
}