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.createdan exam was created via API or dashboard (still in uploading status).
  • exam.confirmedthe exam finished uploading and is now ready. Available to share.
  • exam.deletedthe exam was deleted from the dashboard or via API.
  • exam.updatedan exam field was updated (PATCH /v1/exams/{id}). The payload includes fields and changes with the diff.
  • exam.sharedthe exam was emailed to a recipient (POST /v1/exams/{id}/share). Payload: to, cc, email_id.
  • exam.extra_addedan extra take was confirmed (multi-take for CBCT/ATM). Payload: index, label, file_count, storage_bytes.
  • report.publisheda report PDF was uploaded to the exam (via dashboard or POST /v1/exams/{id}/report).
  • report.signedthe 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.

bash
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"
}
The secret only appears in the create response. Afterwards only the prefix shows (e.g. whsec_abc…). If you lose it, create a new subscription and delete the old one.

Webhook payload

All webhooks share the same root structure: id, type, created_at and data with event-specific info.

json
{
  "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:

http
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-Eventthe event type (e.g. exam.confirmed).
  • X-CBCTHub-Deliveryunique ID for this attempt. Use it for idempotency.
  • X-CBCTHub-SignatureHMAC-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.

javascript
// 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
# 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', 200

Retry 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

POST/api/v1/webhooks

Create subscription. Returns the secret only once.

GET/api/v1/webhooks

List all account subscriptions.

GET/api/v1/webhooks/{id}

Single subscription details.

PATCH/api/v1/webhooks/{id}

Update events, description or enabled.

DELETE/api/v1/webhooks/{id}

Delete subscription. Non-reversible.

POST/api/v1/webhooks/{id}/test

Send a test ping to the configured endpoint.

GET/api/v1/webhooks/{id}/deliveries

Audit 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.