Python
Handle AutoScribe webhooks in Python.
This page is the Python implementation guide: verify signatures, then handle only the events you enabled.
Webhook security
Section titled “Webhook security”All webhook requests from Avara are signed using HMAC-SHA256 with Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature). Copy the webhook secret from API Config (View Webhook Secret) and set it as the AVARA_WEBHOOK_KEY environment variable. The SDK’s unwrap() method reads that variable, verifies the signature, and returns a typed event. Every Avara→partner POST expects HTTP 200 with a JSON body matching that event’s response schema. Synchronous hooks should respond within about 30 seconds.
from avara import Avarafrom flask import Flask, request, jsonify
client = Avara() # reads AVARA_WEBHOOK_KEY from envapp = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'study.access_requested': # Handle study access request study_id = event.data.study_id # ...
if event.type == 'secondary_capture.access_requested': # Handle secondary capture upload request study_id = event.data.study_id # ...
if event.type == 'report.delivered': # Handle report delivery report_id = event.data.report_id # ...
if event.type == 'modality_worklist.requested': # Handle modality worklist request clinic_id = event.data.clinic_id # ...
if event.type == 'patient_study.enrichment_requested': # Handle patient/study enrichment study_instance_uid = event.data.study_instance_uid # ...
if event.type == 'clinical_context.enrichment_requested': # Handle clinical context enrichment study_id = event.data.study_id # ...Study Image Access
Section titled “Study Image Access”The study.access_requested webhook is sent when Avara needs presigned URLs for DICOM images (and optional non-DICOM media). This is a synchronous (hard) webhook — you must respond within about 30 seconds. This webhook is sent before a study can be viewed in the Avara interface.
When Avara needs to access study images, it sends a POST request to your webhook endpoint. Your endpoint must respond with presigned GET URLs. The SDK’s unwrap() method returns a typed StudyAccessRequestedWebhookEvent that you can use directly.
Return authorized: false with an error when the caller must not access the study. Returning authorized: true with an empty urls array (and omitted or empty mediaUrls) is treated as no data available, not as a denial.
from avara import Avarafrom flask import Flask, request, jsonifyfrom typing import List
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'study.access_requested': study_id = event.data.study_id study_instance_uid = event.data.study_instance_uid
# This is your internal business logic presigned_urls = generate_presigned_urls_for_study(study_instance_uid)
if not presigned_urls: return jsonify({ 'authorized': False, 'error': 'Study not found in PACS' }), 200
return jsonify({ 'authorized': True, 'urls': presigned_urls }), 200
# This is your internal business logicdef generate_presigned_urls_for_study(study_instance_uid: str) -> List[str]: # Query your PACS/RIS system for the study # Generate presigned URLs for each DICOM image # Return a flat list of all image URLs return [ 'https://storage.example.com/dicom/image1.dcm?token=abc123', 'https://storage.example.com/dicom/image2.dcm?token=def456', ]Response Format
from typing import TypedDict, NotRequired, List
class StudyAccessRequestedMediaUrl(TypedDict): url: str mimeType: str fileName: NotRequired[str]
class StudyAccessRequestedWebhookResponse(TypedDict): authorized: bool urls: List[str] # Flat list of DICOM GET URLs (may be empty) mediaUrls: NotRequired[List[StudyAccessRequestedMediaUrl]] error: NotRequired[str]Secondary Capture Upload
Section titled “Secondary Capture Upload”The secondary_capture.access_requested webhook is sent when a viewer user creates a secondary capture. This is a synchronous (hard) webhook — you must return presigned PUT URLs in-request, within about 30 seconds.
Avara asks for upload URLs before writing the generated DICOM to your storage. The SDK’s unwrap() method returns a typed SecondaryCaptureAccessRequestedWebhookEvent. Respond authorized: false with an error if the study cannot be written. uploadUrls must be PUT-capable; the client uploads with Content-Type: application/dicom and writes the same object to every URL. Series and SOP UIDs are provided when available so you can pre-compute object keys. If you send contentCreatorName, Avara ignores it and derives the creator name server-side.
from avara import Avarafrom flask import Flask, request, jsonifyfrom typing import List, Optional
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'secondary_capture.access_requested': study_id = event.data.study_id study_instance_uid = event.data.study_instance_uid series_instance_uid = event.data.series_instance_uid sop_instance_uid = event.data.sop_instance_uid
# This is your internal business logic upload_urls = generate_secondary_capture_upload_urls( study_instance_uid, series_instance_uid, sop_instance_uid, )
if not upload_urls: return jsonify({ 'authorized': False, 'error': 'Study cannot be written' }), 200
return jsonify({ 'authorized': True, 'uploadUrls': upload_urls }), 200
# This is your internal business logicdef generate_secondary_capture_upload_urls( study_instance_uid: str, series_instance_uid: Optional[str], sop_instance_uid: Optional[str],) -> List[str]: # Generate presigned PUT URLs for the secondary capture DICOM return [ 'https://storage.example.com/dicom/sc/image.dcm?token=abc123', ]Response Format
from typing import TypedDict, NotRequired, List
class SecondaryCaptureAccessRequestedWebhookResponse(TypedDict): authorized: bool uploadUrls: List[str] contentCreatorName: NotRequired[str] error: NotRequired[str]Report Delivery
Section titled “Report Delivery”The report.delivered webhook is sent when a report is completed and delivered. This is an asynchronous notification — respond with a simple success acknowledgment. The webhook includes plain text content, a presigned URL for PDF download, and isCritical (whether the report was marked critical at sign-off).
When a report is completed, Avara sends a POST request to your webhook endpoint. Your endpoint should process the report and respond with a success acknowledgment. The SDK’s unwrap() method returns a typed ReportDeliveredWebhookEvent that you can use directly.
from avara import Avarafrom flask import Flask, request, jsonify
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'report.delivered': report_id = event.data.report_id study_id = event.data.study_id plain_text = event.data.plain_text presigned_url = event.data.presigned_url is_critical = event.data.is_critical
# This is your internal business logic process_completed_report({ 'report_id': report_id, 'study_id': study_id, 'plain_text': plain_text, 'presigned_url': presigned_url, 'is_critical': is_critical, })
return jsonify({'success': True}), 200
# This is your internal business logicdef process_completed_report(data: dict): # Download the PDF from the presigned URL # Store the report in your system # Update your PACS/RIS with the completed report # Notify relevant users or systems passResponse Format
from typing import TypedDict
class ReportDeliveredWebhookResponse(TypedDict): success: boolModality Worklist
Section titled “Modality Worklist”The modality_worklist.requested webhook is sent when an on-prem modality (via an Avara PACS box) issues a C-FIND MWL. This is a synchronous (hard) webhook — the C-FIND path waits on your response, which should return within about 30 seconds.
Avara forwards the query window to your endpoint. Only one active API key per organization may have the Modality Worklist Webhook Endpoint set. The SDK’s unwrap() method returns a typed ModalityWorklistRequestedWebhookEvent. Return authorized: false with an error to surface a worklist failure to the modality. items is required and may be empty when there are no scheduled exams.
Item field names are PascalCase DICOM-style (not camelCase). StudyInstanceUID is required from the partner RIS today — do not omit it. PatientName, PatientID, and Modality must be non-empty. ScheduledProcedureStepSequence must contain at least one step.
from avara import Avarafrom flask import Flask, request, jsonifyfrom typing import List, Optional, Dict, Any
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'modality_worklist.requested': clinic_id = event.data.clinic_id calling_ae = event.data.calling_ae source_ip = event.data.source_ip date_start = event.data.date_start date_end = event.data.date_end modality = event.data.modality
# This is your internal business logic items = query_modality_worklist( clinic_id, calling_ae, source_ip, date_start, date_end, modality, )
return jsonify({ 'authorized': True, 'items': items }), 200
# This is your internal business logicdef query_modality_worklist( clinic_id: str, calling_ae: str, source_ip: str, date_start: str, date_end: str, modality: Optional[str],) -> List[Dict[str, Any]]: return [ { 'PatientName': 'DOE^JANE', 'PatientID': 'MRN12345', 'PatientBirthDate': '19800115', 'PatientSex': 'F', 'PatientSize': '1.65', 'PatientWeight': '62', 'Modality': 'CT', 'AccessionNumber': 'ACC-1001', 'StudyInstanceUID': '1.2.840.113619.2.55.3.604688119.868.1234567890.123', 'RequestedProcedureDescription': 'CT CHEST WO CONTRAST', 'StudyDescription': 'CT CHEST WO CONTRAST', 'ProtocolName': 'CHEST_WO', 'ScheduledProcedureStepSequence': [ { 'ScheduledProcedureStepStartDate': '20260813', 'ScheduledProcedureStepStartTime': '090000', 'ScheduledProcedureStepID': 'SPSID-1', 'Modality': 'CT', 'ScheduledProcedureStepDescription': 'CT CHEST WO CONTRAST', } ], } ]Response Format
from typing import TypedDict, NotRequired, List
class ModalityWorklistScheduledStep(TypedDict): ScheduledProcedureStepStartDate: str ScheduledProcedureStepStartTime: str ScheduledProcedureStepID: str Modality: str ScheduledProcedureStepDescription: str
class ModalityWorklistItem(TypedDict): PatientName: str PatientID: str PatientBirthDate: str PatientSex: str PatientSize: str PatientWeight: str Modality: str AccessionNumber: str StudyInstanceUID: str RequestedProcedureDescription: str StudyDescription: str ProtocolName: str ScheduledProcedureStepSequence: List[ModalityWorklistScheduledStep]
class ModalityWorklistRequestedWebhookResponse(TypedDict): authorized: bool items: List[ModalityWorklistItem] error: NotRequired[str]Patient / Study Enrichment
Section titled “Patient / Study Enrichment”The patient_study.enrichment_requested webhook is sent after Avara PACS receives the first C-STORE and seeds the study. This is a synchronous (soft) webhook — failures, timeouts, and invalid bodies are treated as empty enrichment and do not hard-fail study creation.
Avara asks your EHR/RIS for demographic and study header fields to merge into AutoScribe study creation. The SDK’s unwrap() method returns a typed PatientStudyEnrichmentRequestedWebhookEvent. There is no authorized field; HTTP 2xx plus JSON that parses is enough. Returning {} is valid. Avara merges per-field with DICOM light metadata, then defaults. Do not rely on this webhook for hard access control.
from avara import Avarafrom flask import Flask, request, jsonifyfrom typing import Optional, Dict, Any
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'patient_study.enrichment_requested': clinic_id = event.data.clinic_id study_instance_uid = event.data.study_instance_uid patient_id = event.data.patient_id accession_number = event.data.accession_number
# This is your internal business logic enrichment = enrich_patient_study( clinic_id, study_instance_uid, patient_id, accession_number, )
return jsonify(enrichment), 200
# This is your internal business logicdef enrich_patient_study( clinic_id: str, study_instance_uid: str, patient_id: Optional[str], accession_number: Optional[str],) -> Dict[str, Any]: # Look up demographics and study headers in your EHR/RIS # Return any subset of fields — or {} return { 'patientName': 'Jane Doe', 'dateOfBirth': '1980-01-15', 'sex': 'female', 'height': { 'value': 165, 'unit': 'cm' }, 'weight': { 'value': 62, 'unit': 'kg' }, 'mrn': 'MRN12345', 'externalPatientId': 'EHR-PT-1001', 'procedure': 'CT CHEST WO CONTRAST', 'studyDescription': 'CT CHEST WO CONTRAST', 'facilityName': 'South Tampa Imaging', 'referringPhysicianName': 'Dr. Alan Smith', 'studyDate': '2026-08-13', 'studyTime': '09:00', 'severity': 'normal', }Response Format
from typing import TypedDict, Literal
class Height(TypedDict): value: float unit: Literal["in", "cm"]
class Weight(TypedDict): value: float unit: Literal["lbs", "kg"]
class PatientStudyEnrichmentRequestedWebhookResponse(TypedDict, total=False): patientName: str dateOfBirth: str sex: Literal["male", "female", "other"] height: Height weight: Weight mrn: str externalPatientId: str procedure: str studyDescription: str facilityName: str referringPhysicianName: str studyDate: str studyTime: str severity: Literal["normal", "high", "stat"]Clinical Context Enrichment
Section titled “Clinical Context Enrichment”The clinical_context.enrichment_requested webhook is sent when AutoScribe needs clinical context for a study (for example, a radiologist opens the study). This is a synchronous (soft) webhook — failures are treated as empty enrichment.
Avara asks your EHR for indication, technique, priors, and supporting documents. The SDK’s unwrap() method returns a typed ClinicalContextEnrichmentRequestedWebhookEvent. There is no authorized field. Returning {} is valid.
data.studyId on this event is a raw UUID v4, not a branded stu_… id. Most other study webhooks use stu_….
documents are verbatim text chunks. documentUrls are fetched and summarized by Avara — prefer https:// URLs.
from avara import Avarafrom flask import Flask, request, jsonifyfrom typing import Optional, Dict, Any
client = Avara()app = Flask(__name__)
@app.route('/webhooks/avara', methods=['POST'])def handle_webhook(): event = client.webhooks.unwrap(request.data, request.headers)
if event.type == 'clinical_context.enrichment_requested': clinic_id = event.data.clinic_id study_instance_uid = event.data.study_instance_uid study_id = event.data.study_id external_patient_id = event.data.external_patient_id mrn = event.data.mrn
# This is your internal business logic enrichment = enrich_clinical_context( clinic_id, study_instance_uid, study_id, external_patient_id, mrn, )
return jsonify(enrichment), 200
# This is your internal business logicdef enrich_clinical_context( clinic_id: str, study_instance_uid: str, study_id: str, external_patient_id: Optional[str], mrn: Optional[str],) -> Dict[str, Any]: # Pull indication, technique, priors, and documents from your EHR # Return any subset of fields — or {} return { 'clinicalIndication': 'Shortness of breath, evaluate for PE', 'technologistTechnique': 'Helical CT chest, 1.25 mm, IV contrast', 'priorReports': [ { 'studyDescription': 'CT CHEST WO CONTRAST', 'modality': 'CT', 'studyDate': '2025-11-02', 'reportText': 'No acute cardiopulmonary process.', } ], }Response Format
from typing import TypedDict, NotRequired, List
class ClinicalContextEnrichmentPriorReport(TypedDict): reportText: str externalStudyId: NotRequired[str] studyDescription: NotRequired[str] modality: NotRequired[str] studyDate: NotRequired[str]
class ClinicalContextEnrichmentDocument(TypedDict): fileName: str content: List[str]
class ClinicalContextEnrichmentDocumentUrl(TypedDict): url: str fileName: NotRequired[str]
class ClinicalContextEnrichmentRequestedWebhookResponse(TypedDict, total=False): clinicalIndication: str technologistTechnique: str technologistNotes: List[str] priorReports: List[ClinicalContextEnrichmentPriorReport] documents: List[ClinicalContextEnrichmentDocument] documentUrls: List[ClinicalContextEnrichmentDocumentUrl]