Upload flow

Upload an exam in 3 steps

The v1 API supports four distinct exam types. Each type accepts a specific set of files and opens a different viewer once the upload finishes. Pick the right type before calling the endpoint: the API validates file extensions and rejects with type_mismatch (400) when files do not match the declared exam_type.

Every type follows the same 3-step mechanic (create → direct PUT to R2 → confirm) and files travel straight to Cloudflare R2 via presigned URLs, never touching our servers. What changes between types is the expected file format, the viewer that opens the share_url, and (for X-rays) the required plan tier plus an optional subtype.

Decision diagram: which type should you pick?

Which exam type are you uploading?
    │
    ├─ Dental CT (CBCT, full FOV) ──────────────────────► exam_type="cbct"
    │       ZIP recommended · 200-600 DICOM · 3D MPR + Pano viewer
    │
    ├─ 2D image (panoramic, periapical, bitewing) ──────► exam_type="radiografia"
    │       1-6 JPEG/PNG · Pro+ plan · 2D viewer
    │       └─ radiograph_subtype: panoramica | teleradiografia |
    │            periapical | bitewing | periapical_total
    │
    ├─ Intraoral 3D scan (iTero, Trios, Medit) ─────────► exam_type="mesh"
    │       1-2 STL/PLY files · 50-200 MB each · 3D Mesh Viewer
    │
    └─ Bilateral TMJ study ─────────────────────────────► exam_type="atm"
            DICOM (same as CBCT) · dedicated bilateral TMJ viewer

Supported exam types

exam_typeExpected filesViewerNotes
cbctDICOM (.dcm, .dicom, .dic, .ima)3D MPR + Panoramic + 3D View — ZIP mode recommended
radiografiaJPEG, PNG or DICOM (1-6 images)2D viewer with W/L and measurement — Free plan NOT allowed (403)
meshSTL or PLY (.stl, .ply)Mesh Viewer (Three.js) — Intraoral 3D scanner
atmDICOM (.dcm, .dicom, .dic, .ima)Bilateral TMJ viewer — Dedicated temporomandibular analysis

Type 1 — CBCT (recommended: ZIP mode)

When to pick it: any dental CT scan (mandible, maxilla, full FOV). A typical CBCT contains 200-600 DICOM files totalling 80-500 MB. Because of the file count we recommend upload_mode="zip": a single PUT call plus the /process-zip endpoint that streams the ZIP server-side, uploads every DICOM to R2 and flips the exam to ready in one operation.

Once finished, share_url and viewer_url open the full CBCT viewer: axial/sagittal/coronal MPR, panoramic reconstruction, oblique slices, 3D View, measurement tools, implant planning and inferior alveolar nerve tracing.

Step 1 — Create the exam in ZIP mode

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

Step 2 — Upload the ZIP with a single 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>

Step 3 — Trigger server-side processing

The server streams the ZIP (without loading it entirely into memory), uploads every DICOM to R2, adjusts storage_used_bytes and marks the exam as ready. Right after, the exam.confirmed webhook fires with the final file count.

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/..."
}
Per-ZIP limits: 1 GB total size, 5000 files inside, 5 GB expanded, 1.6 TB per individual extracted file (covers any multiframe DICOM), 300 s unzip timeout. Specific errors: zip_too_large (413), invalid_zip (400 — corrupt or missing staging), no_dicom_files (400 — ZIP had no DICOMs), too_many_files / too_large_extracted (413), file_too_large (an individual file exceeds the cap).

Automatic multi-take detection

If your ZIP contains 2 or more distinct CBCT studies (typically two folders inside, one per scan), the API auto-detects them by reading each DICOM's SeriesInstanceUID and groups them: the series with the most slices becomes the principal take and the rest become extras (max 3 takes total = principal + 2 extras, same as the dashboard). The viewer will offer a take selector to the recipient. If your ZIP is always a single study, send { "auto_split_series": false } in the /process-zip body to skip the parsing (saves 3-10s per GB).

When multi-take is detected, the /process-zip response includes extra fields:

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

End-to-end Node.js example

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']}")

Type 2 — 2D X-ray

When to pick it: 2D dental images like panoramic, periapical, bitewing, teleradiograph or lateral cephalometric exams. Most uploads are 1 to 6 JPEG or PNG files (2D DICOM is also accepted). The optional radiograph_subtype field tags the image so the viewer surfaces the right tools.

Free plan not allowed

The radiografia type requires Pro plan or higher. A Free account gets 403 plan_restricted when it tries to create the exam. If your integration may serve Free centers, validate the plan before exposing this option.

Valid radiograph_subtype values (all optional):

  • panoramica — classic panoramic X-ray.
  • teleradiografia — lateral cephalometric / teleradiograph.
  • periapical — single periapical X-ray.
  • bitewing — bitewing (interproximal).
  • periapical_total — full periapical series (radiographic status).

Step 1 — Create the exam

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

Step 2 — PUT each image

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

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

Step 3 — Confirm

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);
Compatibility tip: convert images to JPEG before uploading. JPEG loads faster in the 2D viewer, compresses better for email delivery and is what patients expect as an email attachment. Reserve 2D DICOM for cases where you need to preserve equipment metadata.

Type 3 — STL/PLY intraoral scan

When to pick it: 3D models exported by intraoral scanners such as iTero, Trios, Medit, CEREC Primescan or Carestream. Typical uploads are 1 or 2 files (upper.stl + lower.stl) at 50-200 MB each. PLY files from planning software are also accepted.

The viewer opens a Three.js-based Mesh Viewer: free 3D rotation, configurable background, on-mesh measurement, screenshot capture and upper/lower jaw comparison.

Step 1 — Create the exam

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

Step 2 — PUT each STL

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>

Step 3 — Confirm

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

Type 4 — TMJ (Temporomandibular Joint)

When to pick it: bilateral temporomandibular joint studies. Files are DICOM, exactly like CBCT, so the upload flow is identical (ZIP mode recommended). The only difference is that exam_type="atm" makes share_url open the dedicated TMJ viewer, with bilateral right/left analysis, sagittal MIP slab and orientation/crop tools tailored to the joint.

Step 1 — Create the exam in ZIP mode

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_...
If you need to compare both sides with separate captures (dual study), upload the right side first as a TMJ exam and add the second volume from the dashboard or via the viewer extras API. The TMJ viewer lets you assign each capture to the correct side manually.

Mechanics shared by the 4 types: the 3-step flow

Regardless of exam type, the API always exposes the same three-step mechanic. Files travel directly to Cloudflare R2 via presigned URLs (they never touch our servers) and the final confirmation emits the exam.confirmed or exam.created events as appropriate.

Flow diagram

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 ──┤                 │

Idempotency

/confirm and /process-zip are idempotent: calling them multiple times does not duplicate the exam. If your connection drops right after a successful confirm, you can safely retry without being charged storage twice.

What if the upload takes longer than 15 minutes?

Presigned URLs expire after 15 minutes. If your client did not finish in time, call POST /api/v1/exams again to regenerate URLs. The old exam_id stays in status uploading until you delete it with DELETE /api/v1/exams/{id}.

Common errors when creating exams

These are the codes you will see most often in production. All arrive as JSON shaped { "error": { "code": "...", "message": "..." } } with the indicated HTTP status.

CodeHTTPWhen it happensHow to fix
type_mismatch400files[] extensions do not match exam_type (e.g. .stl with exam_type="cbct"). Check the declared exam_type and the allowed extensions for that type (table above).
plan_restricted403Free account trying to create exam_type="radiografia". Suggest upgrading to Pro plan or higher. Other types (cbct, mesh, atm) are available on Free within the storage quota.
quota_exceeded402The exam does not fit in the account remaining storage. available_bytes and requested_bytes are returned in the extra field. Ask the client to free space or upgrade their plan.
zip_too_large413zip_size_bytes (zip mode) exceeds 1 GB. Split the study into separate exams or use upload_mode="files" to upload DICOMs directly (each individual file can be very large).
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"
    }
  }
}