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
| Event | Triggered When |
|---|---|
patient.created | New patient registered |
patient.updated | Patient demographics updated |
health_id.created | New Health ID issued |
health_id.verified | Health ID verified by a facility |
consent.requested | Consent request sent to patient |
consent.granted | Patient grants consent — proceed with data access |
consent.denied | Patient denies a consent request |
consent.revoked | Patient revokes a previously granted consent — stop data access immediately |
encounter.created | New encounter/visit recorded |
encounter.closed | Encounter finalised |
lab_order.created | Lab test ordered |
lab_result.released | Lab result published — trigger CDSS analysis |
lab_result.amended | Lab result corrected — re-run CDSS analysis |
prescription.issued | New prescription written — trigger drug interaction check |
prescription.cancelled | Prescription cancelled |
prescription.dispensed | Prescription dispensed by pharmacy |
appointment.created | Appointment scheduled |
appointment.checked_in | Patient checked in |
appointment.cancelled | Appointment cancelled |
document.issued | New clinical document issued |
document.verified | Document QR verified |
bridge_agent.sync_failed | Bridge 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"
}
}
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()
);
}
}
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:
| Attempt | Delay Before Retry |
|---|---|
| 1 (initial) | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 6 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"
}'