---
title: Python | Avara
description: Handle AutoScribe webhooks in Python.
---

You almost never need all six webhooks. Read [What webhooks should I configure](/integration/autoscribe/what-webhooks-should-i-configure/index.md) first — it maps each integration profile to the endpoints you should set, based on the products you or your customer use and the end behavior you want. Leave unused endpoints blank.

This page is the Python implementation guide: verify signatures, then handle only the events you enabled.

## 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](/integration/autoscribe/api-config/index.md) (**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**.

You can use `unsafeUnwrap()` instead of `unwrap()` to skip signature verification, but this is not recommended for production as it bypasses security checks.

```
from avara import Avara
from flask import Flask, request, jsonify


client = Avara()  # reads AVARA_WEBHOOK_KEY from env
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':
        # 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

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 Avara
from flask import Flask, request, jsonify
from 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 logic
def 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

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 Avara
from flask import Flask, request, jsonify
from 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 logic
def 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

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 Avara
from 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 logic
def 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
    pass
```

**Response Format**

```
from typing import TypedDict


class ReportDeliveredWebhookResponse(TypedDict):
    success: bool
```

## 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 Avara
from flask import Flask, request, jsonify
from 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 logic
def 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

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 Avara
from flask import Flask, request, jsonify
from 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 logic
def 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

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 Avara
from flask import Flask, request, jsonify
from 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 logic
def 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]
```
