Back to blog
apiintegrationtutorialpmsdevelopers

How to integrate your dental practice management software with CBCTHub: a complete API tutorial

Cristian Rosas Méndez

By

If you build or maintain a dental practice management system, your customers shouldn't have to leave your app to share a CBCT scan. They shouldn't burn CDs, attach 800 MB DICOM zips to email, or copy patient names between systems. This tutorial walks through a production-ready integration with the CBCTHub REST API: create an exam, upload the DICOM files, confirm, listen for webhooks, and embed the viewer back inside your PMS.

Everything below targets https://cbcthub.com/api/v1 and works against a live account. Code samples are in Node.js, Python and PHP.

What you get out of integrating

  • Single workflow for the front-desk user: upload DICOM from inside the PMS, get a shareable link automatically.
  • Embedded 3D viewer in your patient record screen, no plugin required.
  • Automatic email/WhatsApp delivery to the referring dentist, branded with the clinic's logo.
  • Webhook notifications so your PMS can move the appointment to "delivered" the moment the exam is ready.

Prerequisites

  • A CBCTHub account (Free works for development, Pro or above for production volume).
  • An API key from Settings → API keys. Format: cbct_live_... for production or cbct_test_... for sandbox.
  • Scopes exams:read and exams:write.
  • A server-side environment. Never call the API from a browser — your key would be exposed.

Quick sanity check

Before writing any code, verify the key works:

curl -H "Authorization: Bearer cbct_test_..." \
  https://cbcthub.com/api/v1/me

A 200 response with your user_id, plan and storage quota confirms you're ready.

Step 1: Create the exam

POST to /api/v1/exams with patient metadata and either a file list or a single ZIP size. The endpoint supports two upload modes:

  • upload_mode: "files" (default): you send N files and get N presigned URLs back. Best when your PMS already knows the DICOM structure.
  • upload_mode: "zip": you send one ZIP and the server unpacks it. Best when your CBCT scanner produces a folder you want to ship as-is.

Node.js — files mode

const res = await fetch('https://cbcthub.com/api/v1/exams', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.CBCTHUB_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'CBCT mandible — implant planning #46',
    exam_type: 'cbct',
    patient_name: 'Sarah Johnson',
    patient_id: 'MRN-449201',
    birth_date: '1978-03-12',
    reason: 'Implant planning, tooth #46',
    expiration_days: 90,
    upload_mode: 'files',
    files: dicomFiles.map(f => ({ name: f.name, size: f.size })),
  }),
});

const { exam_id, upload_urls, confirm_url, share_url, viewer_url } = await res.json();

Python — ZIP mode

import os, requests

resp = requests.post(
    'https://cbcthub.com/api/v1/exams',
    headers={
        'Authorization': f"Bearer {os.environ['CBCTHUB_KEY']}",
        'Content-Type': 'application/json',
    },
    json={
        'name': 'CBCT mandible — implant planning #46',
        'exam_type': 'cbct',
        'patient_name': 'Sarah Johnson',
        'expiration_days': 90,
        'upload_mode': 'zip',
        'zip_size_bytes': os.path.getsize('study.zip'),
    },
    timeout=30,
)
resp.raise_for_status()
data = resp.json()

PHP

$ch = curl_init('https://cbcthub.com/api/v1/exams');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . getenv('CBCTHUB_KEY'),
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'name' => 'CBCT mandible — implant #46',
    'exam_type' => 'cbct',
    'patient_name' => 'Sarah Johnson',
    'expiration_days' => 90,
    'upload_mode' => 'files',
    'files' => $fileList,
  ]),
]);
$response = json_decode(curl_exec($ch), true);

Step 2: Upload the files

The presigned URLs returned by the create call point directly at Cloudflare R2. Your client (or server) does PUTs with the raw bytes — these requests do not count against your API rate limit and don't proxy through CBCTHub. A 500 MB CBCT scan uploads at the full speed of your connection.

Files mode — concurrent uploads

// Node.js — upload all slices with a concurrency limit of 5
import pLimit from 'p-limit';
const limit = pLimit(5);

await Promise.all(
  upload_urls.map((u, i) =>
    limit(() => fetch(u.url, { method: 'PUT', body: dicomFiles[i].buffer }))
  )
);

ZIP mode — single upload + server-side unpack

// 1. PUT the ZIP to the staging URL
await fetch(upload_urls[0].url, {
  method: 'PUT',
  body: fs.readFileSync('study.zip'),
});

// 2. Ask the server to unpack it
const procRes = await fetch(`https://cbcthub.com/api/v1/exams/${exam_id}/process-zip`, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.CBCTHUB_KEY}` },
});
// Response includes the final file count and status: 'ready'

Step 3: Confirm the exam

In files mode you must explicitly close the exam after every PUT succeeds. ZIP mode confirms automatically via /process-zip.

await fetch(confirm_url, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.CBCTHUB_KEY}` },
});

The exam now transitions from uploading to ready. The share and viewer URLs returned in step 1 become active.

Step 4: Receive webhooks

Instead of polling GET /v1/exams/{id} in a loop, register a webhook endpoint and let CBCTHub notify your PMS when the exam is ready, viewed or signed. Each event is signed with HMAC SHA-256 in the X-CBCTHub-Signature header and carries a unique X-CBCTHub-Delivery id for idempotency.

// Express verify
import crypto from 'crypto';

app.post('/cbcthub/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('X-CBCTHub-Signature') ?? '';
  const expected = crypto
    .createHmac('sha256', process.env.CBCTHUB_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  // event.type: 'exam.created' | 'exam.confirmed' | 'report.published' | ...
  return res.status(200).send('ok');
});

See the full event catalog in the webhooks reference.

Step 5: Embed the viewer

The simplest way to surface the exam inside your PMS is to drop an iframe with the viewer_url CBCTHub returned at creation time. The viewer adapts to mobile and supports MPR, panoramic reconstruction, 3D and STL meshes.

<iframe
  src="https://cbcthub.com/viewer/a3f9b1c2-d4e5-..."
  width="100%"
  height="720"
  allow="fullscreen"
  style="border: 0;"
></iframe>

If you need an anonymized embed (no patient name visible), use the dedicated embed token endpoint described in the endpoint reference.

Production best practices

  • Idempotency: if your create call times out, retry with the same payload — duplicate exams are filtered on the server using a hash of (patient_id, name, file count).
  • Retries: exponential backoff on 429 (respect Retry-After) and 5xx only. Never retry 4xx other than 429.
  • Secret management: store the key in your secret manager (AWS Secrets Manager, Vault, Doppler). Rotate every 90 days.
  • Sandbox first: use cbct_test_ keys during development. Test data is isolated and doesn't consume storage.
  • Observability: log the X-Request-ID response header on every call. It's the fastest way to get help if something goes wrong.

Wrap up

A complete dental PMS ↔ CBCTHub integration is roughly 200 lines of code: create, upload, confirm, listen, embed. From there your customers get instant CBCT delivery without leaving your product, and you have a defensible feature in your sales conversations with referring dentists.

Create a free account · Read the full API docs

Try CBCTHub for free

Upload, view, and share DICOM scans in the cloud. Nothing to install.

Create free account