Webhooks

OpesCare pushes real-time events to your HTTPS endpoint whenever something meaningful happens — a lab result published, a prescription issued, a consent granted or revoked. Each delivery is signed with HMAC-SHA256 and includes a timestamp, so you can verify authenticity and reject replays.

Subscribe to Events

Create a subscription by providing your callback_url and the list of events you want to receive. The response includes a webhook_secret — store it immediately, it is shown only once.

curl -X POST https://opescare.test/api/v1/connect/webhooks/subscriptions \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://your-system.example.com/opescare/webhook",
    "subscribed_events": ["lab_result.released", "prescription.issued", "consent.granted"],
    "description": "My CDSS event listener"
  }'
$subscription = Http::withToken($accessToken)
    ->post('https://opescare.test/api/v1/connect/webhooks/subscriptions', [
        'callback_url'      => 'https://your-system.example.com/opescare/webhook',
        'subscribed_events' => ['lab_result.released', 'prescription.issued', 'consent.granted'],
        'description'       => 'My CDSS event listener',
    ])->json();

// Save immediately — shown only once
$webhookSecret = $subscription['webhook_secret']; // "whsec_xxxxxxxxxxxxxxxx"
const res = await fetch('https://opescare.test/api/v1/connect/webhooks/subscriptions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    callback_url:      'https://your-system.example.com/opescare/webhook',
    subscribed_events: ['lab_result.released', 'prescription.issued', 'consent.granted'],
    description:       'My CDSS event listener',
  }),
});
const subscription = await res.json();

// Save immediately — shown only once
const webhookSecret = subscription.webhook_secret; // "whsec_xxxxxxxxxxxxxxxx"
subscription = requests.post(
    'https://opescare.test/api/v1/connect/webhooks/subscriptions',
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'callback_url':      'https://your-system.example.com/opescare/webhook',
        'subscribed_events': ['lab_result.released', 'prescription.issued', 'consent.granted'],
        'description':       'My CDSS event listener',
    }
).json()

# Save immediately — shown only once
webhook_secret = subscription['webhook_secret']  # "whsec_xxxxxxxxxxxxxxxx"

Event Types

EventTriggered When
patient.createdNew patient registered
patient.updatedPatient demographics updated
health_id.createdNew Health ID issued
health_id.verifiedHealth ID verified by a facility
consent.requestedConsent request sent to patient
consent.grantedPatient grants consent — proceed with data access
consent.deniedPatient denies a consent request
consent.revokedPatient revokes a previously granted consent — stop data access immediately
encounter.createdNew encounter/visit recorded
encounter.closedEncounter finalised
lab_order.createdLab test ordered
lab_result.releasedLab result published — trigger CDSS analysis
lab_result.amendedLab result corrected — re-run CDSS analysis
prescription.issuedNew prescription written — trigger drug interaction check
prescription.cancelledPrescription cancelled
prescription.dispensedPrescription dispensed by pharmacy
appointment.createdAppointment scheduled
appointment.checked_inPatient checked in
appointment.cancelledAppointment cancelled
document.issuedNew clinical document issued
document.verifiedDocument QR verified
bridge_agent.sync_failedBridge Agent sync failure — check agent health

Payload Schema

{
  "id":         "evt_01HX9K2ABCD",
  "type":       "lab_result.released",
  "version":    "1.0",
  "created_at": "2026-06-01T10:30:00Z",
  "data": {
    "health_id":     "CM-HID-7KQ9-MP42-X8D1",
    "lab_result_id": "lr_xxxxxx",
    "test_name":     "Complete Blood Count",
    "flagged":       true,
    "facility_id":   "00000000-0000-0000-0000-100000000001"
  },
  "meta": {
    "organization_id": "org-uuid",
    "facility_id":     "00000000-0000-0000-0000-100000000001",
    "environment":     "production",
    "request_id":      "req-uuid"
  }
}
The event type is in the type field (not event). Payload sensitivity varies by event — most deliver only metadata by default. Contact support to request full payload delivery for specific events.

Signature Verification

Every delivery includes an X-OpesCare-Signature header in the format t=timestamp,v1=hmac-hex. The signature is computed as HMAC-SHA256(timestamp + "." + raw_body, webhook_secret). Always verify this before processing any payload.

Also check that the timestamp is within 5 minutes of the current time to reject replay attacks.

// routes/api.php
Route::post('/opescare/webhook', function (\Illuminate\Http\Request $request) {
    $sigHeader = $request->header('X-OpesCare-Signature'); // "t=1717228800,v1=abc123..."
    $body      = $request->getContent();
    $secret    = env('OPESCARE_WEBHOOK_SECRET'); // "whsec_xxxxxxxxxxxxxxxx"

    // Parse t= and v1= from signature header
    $parts = [];
    foreach (explode(',', $sigHeader) as $seg) {
        [$k, $v] = explode('=', $seg, 2);
        $parts[trim($k)] = trim($v);
    }

    // Replay protection — reject events older than 5 minutes
    if (abs(time() - (int)$parts['t']) > 300) {
        abort(400, 'Webhook timestamp out of tolerance');
    }

    // Verify HMAC-SHA256 signature
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
    if (!hash_equals($expected, $parts['v1'])) {
        abort(400, 'Invalid signature');
    }

    $payload = $request->json()->all();
    // Process $payload['type'] ...

    return response()->json(['received' => true]);
});
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/opescare/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sigHeader = req.headers['x-opescare-signature']; // "t=...,v1=..."
    const secret    = process.env.OPESCARE_WEBHOOK_SECRET;

    // Parse t= and v1= from header
    const parts = Object.fromEntries(
      sigHeader.split(',').map(s => s.split('=', 2))
    );

    // Replay protection
    if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(parts.t, 10)) > 300) {
      return res.status(400).send('Timestamp out of tolerance');
    }

    // Verify HMAC-SHA256
    const signed   = `${parts.t}.${req.body.toString()}`;
    const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
    const expBuf   = Buffer.from(expected, 'hex');
    const recBuf   = Buffer.from(parts.v1, 'hex');

    if (expBuf.length !== recBuf.length || !crypto.timingSafeEqual(expBuf, recBuf)) {
      return res.status(400).send('Invalid signature');
    }

    const payload = JSON.parse(req.body);
    // Handle payload.type ...

    res.status(200).json({ received: true });
  }
);
import hmac, hashlib, time, os
from flask import Flask, request, abort, jsonify

app = Flask(__name__)

@app.route('/opescare/webhook', methods=['POST'])
def webhook():
    sig_header = request.headers.get('X-OpesCare-Signature', '')
    secret     = os.environ['OPESCARE_WEBHOOK_SECRET']
    body       = request.get_data()  # raw bytes — do NOT call request.json first

    # Parse t= and v1= from header
    parts = {}
    for seg in sig_header.split(','):
        if '=' in seg:
            k, v = seg.split('=', 1)
            parts[k.strip()] = v.strip()

    # Replay protection — reject events older than 5 minutes
    if abs(int(time.time()) - int(parts.get('t', 0))) > 300:
        abort(400, 'Timestamp out of tolerance')

    # Verify HMAC-SHA256: HMAC(timestamp + "." + raw_body)
    signed   = f"{parts['t']}.".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, parts.get('v1', '')):
        abort(400, 'Invalid signature')

    payload = request.json
    # Handle payload['type'] ...

    return jsonify({'received': True})
// Go example
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func verifyWebhook(body []byte, sigHeader, secret string) bool {
    parts := make(map[string]string)
    for _, seg := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(seg, "=", 2)
        if len(kv) == 2 {
            parts[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
        }
    }

    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false // missing timestamp or replay
    }

    signed := []byte(fmt.Sprintf("%d.", ts))
    signed = append(signed, body...)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(signed)
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    sig    := r.Header.Get("X-OpesCare-Signature")
    secret := os.Getenv("OPESCARE_WEBHOOK_SECRET")

    if !verifyWebhook(body, sig, secret) {
        http.Error(w, "Invalid signature", http.StatusBadRequest)
        return
    }
    fmt.Fprintln(w, `{"received":true}`)
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.*;

public class WebhookVerifier {
    public static boolean verify(byte[] body, String sigHeader, String secret)
            throws Exception {

        // Parse t= and v1= from header
        Map<String, String> parts = new HashMap<>();
        for (String seg : sigHeader.split(",")) {
            String[] kv = seg.strip().split("=", 2);
            if (kv.length == 2) parts.put(kv[0].strip(), kv[1].strip());
        }

        long ts = Long.parseLong(parts.getOrDefault("t", "0"));
        if (Math.abs(System.currentTimeMillis() / 1000L - ts) > 300) {
            return false; // replay protection
        }

        // Compute HMAC-SHA256(timestamp + "." + raw_body)
        byte[] prefix  = (ts + ".").getBytes();
        byte[] signed  = new byte[prefix.length + body.length];
        System.arraycopy(prefix, 0, signed, 0, prefix.length);
        System.arraycopy(body, 0, signed, prefix.length, body.length);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
        String expected = HexFormat.of().formatHex(mac.doFinal(signed));

        return MessageDigest.isEqual(
            expected.getBytes(), parts.getOrDefault("v1", "").getBytes()
        );
    }
}
If you are using an OpesCare SDK, call client.webhooks.verify_signature() — it handles parsing, HMAC verification, and replay protection correctly in all three languages.

Retry Policy

Your endpoint must return a 2xx status within 10 seconds. OpesCare retries failed deliveries on this schedule:

AttemptDelay Before Retry
1 (initial)Immediate
21 minute
35 minutes
415 minutes
51 hour
66 hours
7 (final)24 hours

After the final attempt the delivery is marked exhausted. Use the replay endpoint or developer portal to manually resend. View delivery logs under Apps → Webhook Deliveries.

Replay a Failed Event

curl -X POST https://opescare.test/api/v1/connect/webhooks/events/evt_01HX9K2ABCD/replay \
  -H "Authorization: Bearer {access_token}"

Testing Your Endpoint

During development, use a tunnel tool like ngrok or expose to receive webhooks on localhost:

# Start a tunnel to your local server on port 8000
ngrok http 8000

# Then subscribe using the ngrok URL:
curl -X POST https://opescare.test/api/v1/connect/webhooks/subscriptions \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://abc123.ngrok.io/opescare/webhook",
    "subscribed_events": ["lab_result.released", "prescription.issued"],
    "description": "Local dev test"
  }'