Webhooks
Real-time events with HMAC + retries
Webhooks let you receive automatic HTTP notifications when events happen in CBCTHub. Instead of polling every minute, CBCTHub POSTs to your URL the moment something happens: an exam confirmed, a report signed, a deletion. Ideal to sync with your PMS, trigger workflows, or notify other systems.
Available events
When you create a subscription you choose which events to listen to. These are the types available today:
exam.created— an exam was created via API or dashboard (still in uploading status).exam.confirmed— the exam finished uploading and is now ready. Available to share.exam.deleted— the exam was deleted from the dashboard or via API.exam.updated— an exam field was updated (PATCH /v1/exams/{id}). The payload includes fields and changes with the diff.exam.shared— the exam was emailed to a recipient (POST /v1/exams/{id}/share). Payload: to, cc, email_id.exam.extra_added— an extra take was confirmed (multi-take for CBCT/ATM). Payload: index, label, file_count, storage_bytes.report.published— a report PDF was uploaded to the exam (via dashboard or POST /v1/exams/{id}/report).report.signed— the report was electronically signed and now has a public verification URL.
Create a subscription
Call POST /api/v1/webhooks with your public URL (must be HTTPS) and the events you want. The response includes a secret — store it, you will need it to verify the signature. The secret is shown ONLY ONCE; if you lose it you must create another subscription.
curl -X POST https://cbcthub.com/api/v1/webhooks \
-H "Authorization: Bearer $CBCTHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://miclinica.com/webhooks/cbcthub",
"events": ["exam.confirmed", "report.signed"],
"description": "Sync con PMS interno"
}'
→ {
"id": "wh_a1b2c3d4e5",
"url": "https://miclinica.com/webhooks/cbcthub",
"events": ["exam.confirmed", "report.signed"],
"description": "Sync con PMS interno",
"enabled": true,
"secret": "whsec_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6",
"created_at": "2026-06-23T14:32:18Z"
}Webhook payload
All webhooks share the same root structure: id, type, created_at and data with event-specific info.
{
"id": "evt_9z8y7x6w5v",
"type": "exam.confirmed",
"created_at": "2026-06-23T14:33:01Z",
"data": {
"exam_id": "exm_abc123",
"exam_type": "cbct",
"patient_name": "Juan Pérez",
"patient_id": "12345678-9",
"status": "ready",
"share_url": "https://cbcthub.com/share/...",
"viewer_url": "https://cbcthub.com/viewer/..."
}
}HTTP headers
Each delivery includes these headers so you can validate origin and integrity:
POST /webhooks/cbcthub HTTP/1.1
Host: miclinica.com
Content-Type: application/json
User-Agent: CBCTHub-Webhooks/1.0
X-CBCTHub-Event: exam.confirmed
X-CBCTHub-Delivery: dlv_unique-per-attempt
X-CBCTHub-Signature: sha256=base64-encoded-hmac
<json body>X-CBCTHub-Event— the event type (e.g. exam.confirmed).X-CBCTHub-Delivery— unique ID for this attempt. Use it for idempotency.X-CBCTHub-Signature— HMAC-SHA-256 signature of the raw body, base64-encoded, with sha256= prefix.
Verify the HMAC signature
Compute HMAC-SHA-256 of the raw body bytes (not the parsed JSON) using your secret and compare in constant time with the header signature. If they do not match, drop the request — someone tried to forge it.
// Node.js — Express ejemplo
const crypto = require('crypto');
const express = require('express');
const app = express();
// Captura el body crudo (NO uses express.json() acá: pierde los bytes originales).
app.post('/webhooks/cbcthub',
express.raw({ type: 'application/json' }),
(req, res) => {
const signatureHeader = req.headers['x-cbcthub-signature'] || '';
const signature = signatureHeader.replace('sha256=', '');
const rawBody = req.body; // Buffer
const computed = crypto
.createHmac('sha256', process.env.CBCTHUB_WEBHOOK_SECRET)
.update(rawBody)
.digest('base64');
const sigBuf = Buffer.from(signature);
const computedBuf = Buffer.from(computed);
if (sigBuf.length !== computedBuf.length ||
!crypto.timingSafeEqual(sigBuf, computedBuf)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
console.log('Evento recibido:', event.type, event.data);
// Responde rápido y procesa en background
res.status(200).send('ok');
}
);# Python — Flask ejemplo
import os, hmac, hashlib, base64
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['CBCTHUB_WEBHOOK_SECRET'].encode('utf-8')
@app.route('/webhooks/cbcthub', methods=['POST'])
def cbcthub_webhook():
raw = request.get_data() # bytes
header = request.headers.get('X-CBCTHub-Signature', '')
signature = header.replace('sha256=', '')
digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
computed = base64.b64encode(digest).decode('utf-8')
if not hmac.compare_digest(signature, computed):
abort(401)
event = request.get_json()
print('Evento:', event['type'], event['data'])
return 'ok', 200Retry policy
If your endpoint does not return 2xx within 10 seconds, we retry with exponential backoff: 30s, 2min, 10min, 1h, 6h, 24h. Max 6 attempts per event. After 20 consecutive failures across any events, the subscription is automatically disabled (enabled=false) so we do not hammer your endpoint.
ℹ Retry schedule
- Attempt 1: immediate
- Attempt 2: +30 seconds
- Attempt 3: +2 minutes
- Attempt 4: +10 minutes
- Attempt 5: +1 hour
- Attempt 6: +6 hours
- Final: +24 hours (then dropped)
Endpoints
/api/v1/webhooksCreate subscription. Returns the secret only once.
/api/v1/webhooksList all account subscriptions.
/api/v1/webhooks/{id}Single subscription details.
/api/v1/webhooks/{id}Update events, description or enabled.
/api/v1/webhooks/{id}Delete subscription. Non-reversible.
/api/v1/webhooks/{id}/testSend a test ping to the configured endpoint.
/api/v1/webhooks/{id}/deliveriesAudit log of the last 50 deliveries.
Best practices
- ALWAYS verify the HMAC signature. Without verification anyone can send a forged POST to your public endpoint.
- Implement idempotency with X-CBCTHub-Delivery. If you get the same delivery_id twice (because of a retry after your own timeout), process it only once.
- Respond 2xx fast (< 10s) and process in background. If your business logic takes time, return 200 OK and enqueue an internal job.
- Use GET /v1/webhooks/{id}/deliveries to audit the last 50 deliveries: status code, response body, retry count. Useful for debugging.
- In staging use POST /v1/webhooks/{id}/test to send yourself a test ping without waiting for a real event.