[feature] EPIC: Webhooks
EHRS Webhook Feature — GitLab Issues (Svix-Based Approach)
Architecture: Svix self-hosted (MIT) handles outbound delivery, retries, HMAC signing, subscription management, and logs. EHRS FastAPI code only calls
svix.message.create()at each event hook point. Inbound webhooks are implemented natively in FastAPI (Svix does not cover inbound). ARQ (async-native Redis queue) used for inbound async processing.Milestone:
Webhook Integration v1Labels referenced:feature,webhooks,infrastructure,patients,consultations,prescriptions,queue,camps,inbound,devops
ISSUE 1 — [Webhooks] Set up Svix self-hosted service
Labels: feature, webhooks, infrastructure, devops
Milestone: Webhook Integration v1
Weight: 3
Background
Svix is an MIT-licensed open-source webhook server written in Rust. It handles all outbound webhook complexity — delivery, retries with exponential backoff, HMAC-SHA256 signing, subscription/endpoint management, delivery logs, and an embeddable consumer portal — so EHRS doesn't need to build any of that from scratch.
EHRS backend becomes a pure event producer: one svix.message.create() call per event.
Svix does the rest.
Tasks
-
Add Svix to docker-compose.yml(or equivalent deployment config):svix-server: image: svix/svix-server:latest environment: SVIX_JWT_SECRET: "${SVIX_JWT_SECRET}" SVIX_DB_DSN: "postgresql://postgres:postgres@db/svix" SVIX_REDIS_DSN: "redis://redis:6379" SVIX_QUEUE_TYPE: "redis" ports: - "8071:8071" depends_on: - db - redis -
Create a dedicated svixdatabase schema (or separate DB) in the existing Postgres instance -
Add SVIX_SERVER_URLandSVIX_AUTH_TOKENto EHRS environment config /.env -
Install Svix Python SDK: pip install svix -
Create app/webhooks/svix_client.py— singletonSvixAsyncclient initialised from env -
Create the EHRS Application in Svix on startup (one application = one EHRS tenant): from svix.api import SvixAsync, ApplicationIn svix = SvixAsync(settings.SVIX_AUTH_TOKEN, server_url=settings.SVIX_SERVER_URL) await svix.application.get_or_create(ApplicationIn(name="ehrs", uid="ehrs-main")) -
Register all event types from the Event Catalogue as Svix EventTypeobjects on startup (enables schema validation and UI labels) -
Add a health check for the Svix sidecar in the EHRS startup sequence — degrade gracefully if Svix is unavailable (log warning, don't block API responses) -
Write integration smoke test: send a test event, assert Svix returns a message ID
Acceptance Criteria
-
docker compose upstarts Svix alongside EHRS with no manual steps -
Svix dashboard accessible at http://localhost:8071 -
svix_client.pyreturns a workingSvixAsyncinstance in tests and in production -
EHRS starts normally and logs a warning (not an error) if Svix is unreachable
References
- Svix self-hosted docs: https://docs.svix.com/self-hosting
- Svix Python SDK: https://github.com/svix/svix-webhooks/tree/main/python
ISSUE 2 — [Webhooks] Create WebhookDispatcher service (thin Svix wrapper)
Labels: feature, webhooks, infrastructure
Milestone: Webhook Integration v1
Depends on: Issue 1
Weight: 2
Background
All route-level event hooks call a single WebhookDispatcher.dispatch() method.
This abstraction keeps route code clean (one line per event), centralises error handling, and
makes it easy to swap Svix out in future if needed.
Tasks
-
Create app/webhooks/dispatcher.py:class WebhookDispatcher: async def dispatch( self, event_type: str, # e.g. "patient.registered" payload: dict, # event-specific data (the `data` field) event_id: str | None = None, # for idempotency; auto-generated if None ) -> None: """ Wraps svix.message.create(). Fires and forgets — never raises into caller. Logs failures internally without propagating. """ -
Use FastAPI BackgroundTasksto enqueue the Svix call — the originating HTTP response must return before Svix is called (≤50ms requirement from spec Section 12.1) -
Build the standard envelope fields ( webhook_id,event_id,timestamp,api_version) inside the dispatcher — route code only passesevent_type+data -
Catch and log all Svix exceptions — delivery failures must never crash or slow the originating EHRS API call -
Inject WebhookDispatcheras a FastAPI dependency so it can be mocked in tests
Acceptance Criteria
-
Calling dispatcher.dispatch("patient.registered", {...})returns instantly (background task, non-blocking) -
If Svix is down, the originating API call succeeds and the error is logged with full context (event_type, payload, exception) -
Unit tests: mock Svix client, assert message.createis called with correctevent_typeand payload; assert exceptions don't propagate
ISSUE 3 — [Webhooks] Patient route event hooks
Labels: feature, webhooks, patients
Milestone: Webhook Integration v1
Depends on: Issue 2
Weight: 2
Events covered
| Event | Trigger endpoint |
|---|---|
patient.registered |
POST /api/v1/patients/ |
patient.updated |
PATCH /api/v1/patients/{book_no} |
patient.camp_registered |
POST /api/v1/patients/{book_no}/register |
vitals.recorded |
PUT /api/v1/patients/vitals/{book_no} |
Tasks
patient.registered — in create_patient_endpoint:
-
After successful DB insert, call: await dispatcher.dispatch("patient.registered", { "book_no": patient.book_no, "patient_name": patient.patient_name, "patient_age": patient.patient_age, "patient_sex": patient.patient_sex, "patient_area": patient.patient_area, "patient_phone_no": patient.patient_phone_no, "created_at": patient.created_at.isoformat(), })
patient.updated — in update_patient_endpoint:
-
Compare pre-update and post-update state; only dispatch if at least one field changed -
Payload: full updated patient fields + updated_at
patient.camp_registered — in register_patient_endpoint:
-
Resolve current camp_idfrom the active camp context -
Payload: book_no,camp_id,specialization(if provided),registered_at
vitals.recorded — in update_vitals_for_latest_visit_endpoint:
-
Enrich payload with camp_idfrom the patient's current visit record -
Payload: all vitals fields from VitalUpdateschema +book_no,camp_id,recorded_at -
Note for Svix consumer filtering: if subscribers need to filter by patient_sex, they should use Svix's tag feature — addpatient_sex:<value>as a message tag so Svix can route by it without embedding sex in every payload unnecessarily
Acceptance Criteria
-
Each of the 4 events fires to Svix after a successful DB operation -
None of the 4 events fire if the DB operation fails or raises an exception -
patient.updateddoes not fire for no-op PATCH requests (no fields changed) -
Unit tests for all 4 hooks: success path (assert dispatch called), failure path (assert dispatch not called), and no-op path for patient.updated
ISSUE 4 — [Webhooks] Consultation route event hooks
Labels: feature, webhooks, consultations
Milestone: Webhook Integration v1
Depends on: Issue 2
Weight: 2
Events covered
| Event | Trigger endpoint |
|---|---|
consultation.created |
POST /api/v1/doctors/assign-doctor |
consultation.updated |
PUT /api/v1/consultations/{consultation_id} |
consultation.completed |
PUT /api/v1/queue/status-update when status=completed
|
Tasks
consultation.created — in assign_patient_to_consultation_endpoint:
-
Dispatch after successful doctor assignment -
Payload: consultation_id,book_no,camp_id,doctor_id,doctor_name,specialization,created_at
consultation.updated — in update_consultation_endpoint:
-
Dispatch after successful update; only if chief_complaintorfollow_up_requiredactually changed -
Payload: consultation_id,book_no,chief_complaint,follow_up_required,updated_at
consultation.completed — in update_queue_status_endpoint:
-
Dispatch only when incoming status == "completed" -
Resolve full consultation details (doctor name, specialization, etc.) from DB for payload -
Payload matches spec Section 8.2 consultation.completedshape:consultation_id,book_no,camp_id,doctor_id,doctor_name,specialization,chief_complaint,follow_up_required,completed_at
Acceptance Criteria
-
All 3 events fire only on successful DB writes -
consultation.completedfires only whenstatus=completed, not on other transitions -
consultation.updateddoes not fire for no-op updates -
Unit tests for all 3 events including the status-filter logic for consultation.completed
ISSUE 5 — [Webhooks] Prescription route event hooks
Labels: feature, webhooks, prescriptions
Milestone: Webhook Integration v1
Depends on: Issue 2
Weight: 2
Events covered
| Event | Trigger |
|---|---|
prescription.added |
POST /api/v1/consultations/{consultation_id}/prescription |
prescription.dispensed |
PUT /api/v1/consultations/prescriptions/{id} when dispatch_status=given
|
prescription.out_of_stock |
PUT /api/v1/consultations/prescriptions/{id} when dispatch_status=out_of_stock
|
prescription.replaced |
PUT /api/v1/consultations/prescriptions/{id} when replaced_by_medicine_id set |
Tasks
prescription.added — in add_prescriptions_to_consultation_endpoint:
-
Dispatch once per added batch (not once per medicine line); include full list in payload -
Payload: consultation_id,book_no,camp_id, list of{medicine_id, medicine_formulation, quantity, days, time_slots},added_at
prescription.dispensed — in update_prescription_endpoint:
-
Fire when dispatch_statustransitions to"given" -
Payload: full prescription.dispensedshape from spec Section 8.2
prescription.out_of_stock — in update_prescription_endpoint:
-
Fire when dispatch_statustransitions to"out_of_stock" -
Payload: prescription_id,consultation_id,book_no,camp_id,medicine_id,medicine_formulation,out_of_stock_at
prescription.replaced — in update_prescription_endpoint:
-
Fire when replaced_by_medicine_idis set on the prescription -
Payload: prescription_id,original_medicine_id,replacement_medicine_id,replacement_medicine_formulation,book_no,camp_id,replaced_at
Note:
prescription.dispensed,prescription.out_of_stock, andprescription.replacedare all triggered from the sameupdate_prescription_endpoint. Determine which event to fire (if any) based on what changed in the update payload — evaluate after the DB write using the returned updated prescription state.
Acceptance Criteria
-
Only one prescription event fires per PUTcall (mutually exclusive based on state) -
No event fires if dispatch_statusis unchanged orreplaced_by_medicine_idis not set -
Unit tests for all 4 events including the mutual-exclusivity logic
ISSUE 6 — [Webhooks] Queue route event hooks
Labels: feature, webhooks, queue
Milestone: Webhook Integration v1
Depends on: Issue 2
Weight: 1
Events covered
| Event | Trigger endpoint |
|---|---|
queue.status_changed |
PUT /api/v1/queue/status-update (all transitions) |
queue.priority_changed |
PUT /api/v1/queue/priority |
Tasks
queue.status_changed — in update_queue_status_endpoint:
-
Dispatch on every successful status transition -
Payload: book_no,camp_id,queue_number,previous_status,new_status,priority,changed_at,changed_by(user UUID) -
Note: consultation.completed(Issue 4) is a separate event for thecompletedtransition — bothqueue.status_changedANDconsultation.completedshould fire whenstatus=completed(they serve different subscriber audiences)
queue.priority_changed — in update_queue_priority_endpoint:
-
Dispatch after successful priority update -
Payload: book_no,camp_id,queue_number,previous_priority,new_priority,changed_at,changed_by -
Only dispatch if priority actually changed (fetch current value before update)
Acceptance Criteria
-
queue.status_changedfires on every valid transition through the full workflow -
queue.priority_changeddoes not fire if the new priority equals the existing one -
Unit tests covering both events
ISSUE 7 — [Webhooks] Camp route event hooks
Labels: feature, webhooks, camps
Milestone: Webhook Integration v1
Depends on: Issue 2
Weight: 1
Events covered
| Event | Trigger endpoint |
|---|---|
camp.created |
POST /api/v1/medical-camps/ |
camp.updated |
PUT /api/v1/medical-camps/{camp_id} |
camp.deleted |
DELETE /api/v1/medical-camps/{camp_id} |
camp.attendance_updated |
POST /api/v1/medical-camps/{camp_id}/update-attended-count |
Tasks
camp.created — in create_medical_camp_endpoint:
-
Payload: camp_id,camp_date,location,expected_patients,created_at
camp.updated — in update_medical_camp_endpoint:
-
Only dispatch if at least one field changed -
Payload: camp_id+ all updated fields +updated_at
camp.deleted — in delete_medical_camp_endpoint:
-
Dispatch before or immediately after deletion (capture camp_id,camp_date,locationbefore row is removed) -
Payload: camp_id,camp_date,location,deleted_at
camp.attendance_updated — in update_attended_patients_count_endpoint:
-
Payload: camp_id,camp_date,attended_patients,updated_at
Acceptance Criteria
-
All 4 events fire only on successful DB operations -
camp.updateddoes not fire for no-op updates -
camp.deletedcaptures the required fields before deletion completes -
Unit tests for all 4 events
ISSUE 8 — [Webhooks] Inbound webhook receiver (native FastAPI)
Labels: feature, webhooks, inbound, infrastructure
Milestone: Webhook Integration v1
Depends on: Issue 1 (for ARQ/Redis setup)
Weight: 3
Background
Svix only handles outbound webhooks. Inbound webhooks (external systems pushing data into EHRS — e.g. lab results, pharmacy stock updates) must be implemented natively in FastAPI. ARQ (asyncio-native Redis queue) is used for async processing of inbound payloads so the receiver responds immediately and processes in the background.
Models & Schemas
-
Create DB model InboundWebhookSource:id (UUID / source_id), name, allowed_event_types (JSON array), secret (bcrypt hashed), is_active, action_mapping (JSON), created_at, updated_at -
Create Pydantic schemas: InboundSourceCreate,InboundSourceResponse,InboundWebhookPayload
Inbound Receiver Endpoint
-
Implement POST /api/v1/webhooks/inbound/{source_id}:- Validate
source_idexists andis_active=true→ 404 if not found, 403 if inactive - Check
X-EHRS-Timestampheader — reject with400if older than 5 minutes - Verify
X-EHRS-Signature: sha256=<hmac>usingHMAC-SHA256(secret, "{timestamp}.{raw_body}")→401if invalid - Validate
event_typeinsource.allowed_event_types→422if not permitted - Enqueue processing job via ARQ and return
202 Acceptedimmediately
- Validate
ARQ Worker — Inbound Action Processor
-
Install ARQ: pip install arq -
Create app/webhooks/inbound_worker.pywith the action handler:async def process_inbound_webhook(ctx, source_id: str, event_type: str, payload: dict): # look up action_mapping[event_type] → dispatch to EHRS internal service # e.g. "lab.result.ready" → update_patient_extra_note(book_no, result) -
Implement action handlers for the two initial inbound event types: -
lab.result.ready→update_patient_extra_note: updateextra_noteon the patient's latest vitals record -
pharmacy.stock.update→update_medicine_inventory: update medicine quantity
-
Inbound Source Management API (admin only)
-
GET /api/v1/webhooks/inbound-sources— list all -
POST /api/v1/webhooks/inbound-sources— register new source (secret shown once, stored bcrypt-hashed) -
GET /api/v1/webhooks/inbound-sources/{id}— get details -
PUT /api/v1/webhooks/inbound-sources/{id}— update -
DELETE /api/v1/webhooks/inbound-sources/{id}— delete
Acceptance Criteria
-
POST /webhooks/inbound/{source_id}with invalid HMAC returns401 Unauthorized -
POST /webhooks/inbound/{source_id}with timestamp >5 minutes old returns400 Bad Request -
Valid inbound payload returns 202 Acceptedwithin 50ms regardless of processing time -
lab.result.readypayload correctly updates the patient'sextra_notefield -
Inbound source secret stored as bcrypt hash; never returned in API responses -
Unit tests for HMAC verification, timestamp replay check, and action dispatch
ISSUE 9 — [Webhooks] Subscription & delivery log management via Svix API proxy
Labels: feature, webhooks, infrastructure
Milestone: Webhook Integration v1
Depends on: Issue 1
Weight: 2
Background
Svix already provides full CRUD for subscriptions (called "Endpoints" in Svix), delivery logs, retry, and a test-ping — all accessible via the Svix Admin API and Svix dashboard. This issue creates thin EHRS admin proxy endpoints that forward to Svix so that EHRS frontend and admin tooling talk only to EHRS, not directly to the Svix sidecar.
All endpoints require admin role.
Outbound Subscription Proxy Endpoints
| Method | EHRS Path | Svix equivalent | Notes |
|---|---|---|---|
GET |
/webhooks/subscriptions |
GET /api/v1/app/{app_id}/endpoint/ |
List all |
POST |
/webhooks/subscriptions |
POST /api/v1/app/{app_id}/endpoint/ |
Reject non-HTTPS URLs |
GET |
/webhooks/subscriptions/{id} |
GET /api/v1/app/{app_id}/endpoint/{id}/ |
|
PUT |
/webhooks/subscriptions/{id} |
PUT /api/v1/app/{app_id}/endpoint/{id}/ |
|
DELETE |
/webhooks/subscriptions/{id} |
DELETE /api/v1/app/{app_id}/endpoint/{id}/ |
|
PUT |
/webhooks/subscriptions/{id}/toggle |
PATCH disable/enable on Svix |
|
POST |
/webhooks/subscriptions/{id}/test |
POST /api/v1/app/{app_id}/endpoint/{id}/send-example/ |
|
POST |
/webhooks/subscriptions/{id}/rotate-secret |
POST /api/v1/app/{app_id}/endpoint/{id}/secret/rotate/ |
10-min grace window built into Svix |
Delivery Log Proxy Endpoints
| Method | EHRS Path | Svix equivalent |
|---|---|---|
GET |
/webhooks/logs |
GET /api/v1/app/{app_id}/msg/ with filters |
GET |
/webhooks/logs/{log_id} |
GET /api/v1/app/{app_id}/attempt/endpoint/{id}/ |
POST |
/webhooks/logs/{log_id}/retry |
POST /api/v1/app/{app_id}/msg/{msg_id}/endpoint/{ep_id}/resend/ |
Tasks
-
Implement all proxy endpoints above as thin async pass-throughs using httpx.AsyncClient -
Enforce adminrole on all endpoints via existing FastAPI auth dependency -
Reject non-HTTPS endpoint_urlvalues at the EHRS layer before forwarding to Svix (return422 Unprocessable Entity) -
Map EHRS subscription fields ( name,event_types,filter_rules,headers) to SvixEndpointInschema on create/update -
Handle Svix API errors and map to appropriate EHRS HTTP responses -
Write integration tests against a local Svix test instance
Acceptance Criteria
-
Admin can create, update, enable/disable, and delete subscriptions via EHRS API -
HTTP (non-HTTPS) endpoint URLs rejected at EHRS layer with 422 -
Secret rotation works; Svix handles the 10-minute grace window automatically -
Test-ping returns the consumer's HTTP response code and body to the admin -
All log endpoints return data in the EHRS WebhookDeliveryLogschema shape (mapped from Svix response)
ISSUE 10 — [Webhooks] Auto-pause on repeated failures & admin alerting
Labels: feature, webhooks, infrastructure
Milestone: Webhook Integration v1
Depends on: Issue 9
Weight: 1
Background
Per spec Section 11.3: if a subscription accumulates 5 consecutive permanently_failed
deliveries, it must be auto-paused and the admin alerted. Svix exposes operational webhooks
(meta-webhooks) that fire on delivery failures — these can be used to implement this logic.
Tasks
-
Register an EHRS-internal Svix operational webhook listener on the endpoint POST /api/v1/webhooks/internal/svix-ops(not exposed publicly) -
In the listener, track consecutive MessageAttemptFailedevents per endpoint (store counter in Redis with a TTL that resets on success) -
When counter hits 5: - Call Svix endpoint disable API to set
disabled=true - Create an internal notification record (or fire an existing EHRS notification mechanism) alerting admins with: subscription name, endpoint URL, last 5 failure reasons
- Reset the counter
- Call Svix endpoint disable API to set
-
Reset counter to 0 on any successful delivery for that endpoint -
Expose GET /api/v1/webhooks/subscriptions/{id}/failure-statsfor admin visibility
Acceptance Criteria
-
Subscription is auto-disabled after 5 consecutive permanent failures -
Admin receives a system notification with failure details -
Successful delivery resets the failure counter -
Unit tests for counter logic (increment, reset, threshold trigger)
ISSUE 11 — [Webhooks] OpenAPI schema — declare all webhook event shapes
Labels: feature, webhooks, infrastructure
Milestone: Webhook Integration v1
Depends on: Issues 3–7
Weight: 1
Background
FastAPI 0.99+ supports @app.webhooks.post() for documenting outbound webhook schemas in the
OpenAPI spec. This makes the webhook contract discoverable via /docs and allows consumers
to auto-generate client code. This is documentation only — no delivery logic here.
Tasks
-
Create app/webhooks/openapi_webhooks.py -
Declare Pydantic response models for every event type payload (18 events total) — reuse existing schemas where possible ( Patient,Vital,PatientConsultation, etc.) -
Register each event using @app.webhooks.post("{event_type}")with the correct response model -
Add the standard envelope wrapper as an outer model: class WebhookEnvelope(BaseModel): webhook_id: str subscription_id: UUID event_type: str event_id: str timestamp: datetime api_version: str = "1.0.0" data: dict # overridden by specific event models in each webhook declaration -
Verify all 18 events appear under the "Webhooks" section in /docs
Acceptance Criteria
-
All events from the Event Catalogue appear in the OpenAPI Webhooks section -
Each event shows its correct payload schema with field descriptions -
No existing API routes or tests broken by this addition
Dependency Graph
Issue 1 ──── Svix self-hosted setup
│
Issue 2 ──── WebhookDispatcher (thin wrapper)
│
├── Issue 3 ── Patient route hooks
├── Issue 4 ── Consultation route hooks
├── Issue 5 ── Prescription route hooks
├── Issue 6 ── Queue route hooks
└── Issue 7 ── Camp route hooks
Issue 1 ──── Issue 8 ── Inbound webhook receiver (native FastAPI + ARQ)
Issue 1 ──── Issue 9 ── Subscription & log management proxy
Issue 9 ──── Issue 10 ── Auto-pause on repeated failures
Issues 3-7 ── Issue 11 ── OpenAPI webhook schema declarations
What Svix handles automatically (no EHRS code needed)
| Requirement (from spec) | Handled by Svix |
|---|---|
| HMAC-SHA256 signing on every delivery | |
X-EHRS-Signature and X-EHRS-Timestamp headers |
|
| Exponential backoff retry schedule | |
permanently_failed status after retry exhaustion |
|
| Delivery logs with request/response bodies | |
| 90-day log retention config |
|
| Secret rotation with grace window | |
| HTTPS-only enforcement |
|
| TLS certificate validation | |
| Test-ping endpoint | |
Idempotency via event_id
|
|
| Batch delivery option | |
| 500 deliveries/min throughput |
What EHRS still builds
| Requirement | Issue |
|---|---|
| Svix deployment & integration | #1 (closed) |
| Dispatcher service (one-liner per event) | #2 (closed) |
| Event hooks in patient routes | #3 (closed) |
| Event hooks in consultation routes | #4 (closed) |
| Event hooks in prescription routes | #5 (closed) |
| Event hooks in queue routes | #6 (closed) |
| Event hooks in camp routes | #7 (closed) |
| Inbound webhook receiver + ARQ worker | #8 (closed) |
| EHRS admin proxy for Svix subscription/log APIs | #9 (closed) |
| Auto-pause + admin alerting logic | #10 (closed) |
| OpenAPI webhook schema documentation | #11 (closed) |