Recipe 1.2 Architecture and Implementation: Patient Intake Form Digitization
Companion to Recipe 1.2: Patient Intake Form Digitization. This page covers the AWS architecture, services, prerequisites, and pseudocode. For the problem framing and the conceptual approach, start with the main recipe.
The AWS Implementation
Why These Services
Amazon Textract for async multi-page extraction. Textract's StartDocumentAnalysis / GetDocumentAnalysis API pair is designed exactly for this use case: multi-page PDF and TIFF documents that need both FORMS (key-value pairs) and TABLES feature extraction in a single job. You submit the job, Textract processes all pages in parallel, and you retrieve results when it signals completion. The unified response includes everything: key-value pairs with spatial data, table cells with row and column indices, selection element states for checkboxes, and raw text blocks. You don't have to choose between feature types upfront; you request all of them at once.
Amazon SNS for job completion signaling. Textract's async API integrates directly with SNS for completion notifications. When a job finishes, Textract publishes a message to your SNS topic containing the job ID and completion status. This is the push notification model that eliminates polling. You configure a second Lambda function as a subscriber to that SNS topic, and it fires automatically when Textract is done. This is the cleanest possible implementation of the async pattern: no polling loops, no sleep-and-retry logic, no wasted API calls checking on jobs that aren't done yet.
Two Lambda functions for orchestration. The split into two Lambdas is a direct consequence of the async job model. The first Lambda is triggered by the S3 upload event, submits the Textract job, and exits (its work is done in milliseconds). The second Lambda is triggered by the SNS notification, retrieves the results, does the parsing and normalization work, and writes to DynamoDB. This separation keeps each function small, focused, and easy to reason about. Each one does one thing.
Amazon S3 for document storage. Same pattern as Recipe 1.1, with one addition: intake forms contain substantially more PHI than an insurance card. Demographics, Social Security Numbers (last four, at minimum), medical history, medications, allergies, insurance details. The encryption and access control posture needs to be correspondingly tighter. S3 with SSE-KMS, strict bucket policies, and VPC endpoint access only is the right default for documents of this sensitivity.
Getting documents to S3 from on-premises systems. This recipe assumes documents arrive in S3 via upload or event trigger. Many healthcare organizations operate hybrid architectures with on-premises fax servers, EHR document export jobs, or legacy scanning infrastructure. For sustained document pipelines, AWS Direct Connect provides sub-10ms dedicated connectivity. For moderate volumes, Site-to-Site VPN over the internet is adequate. For bulk historical uploads, AWS DataSync handles the transfer. The processing pipeline in this recipe is the same regardless of how documents arrive in S3.
Amazon DynamoDB for results. The structured output of a patient intake form is a richer object than an insurance card record, but the access patterns are similar: write once at extraction time, look up later by patient or document key. DynamoDB's flexible schema handles the variable structure well, since not every form has every section filled, and the presence or absence of tables varies by specialty.
Architecture Diagram
flowchart LR
A[๐ Scanner / Fax Server] -->|PDF Upload| B[S3 Bucket\nintake-forms/]
B -->|S3 Event| C[Lambda\nintake-start]
C -->|StartDocumentAnalysis\nFORMS + TABLES| D[Amazon Textract]
D -->|Job Complete| E[SNS Topic\ntextract-jobs]
E -->|Notification| F[Lambda\nintake-process]
F -->|GetDocumentAnalysis\npaginated| D
F -->|Structured Record| G[DynamoDB\nintake-extractions]
F -->|Flagged Fields| H[Review Queue\nโ Recipe 1.6]
style B fill:#f9f,stroke:#333
style D fill:#ff9,stroke:#333
style G fill:#9ff,stroke:#333
style E fill:#ffa,stroke:#333
Prerequisites
| Requirement | Details |
|---|---|
| AWS Services | Amazon Textract, Amazon S3, AWS Lambda (ร2), Amazon SNS, Amazon DynamoDB |
| IAM Permissions | textract:StartDocumentAnalysis, textract:GetDocumentAnalysis, s3:GetObject, s3:PutObject, sns:Publish, sns:Subscribe, dynamodb:PutItem, iam:PassRole (to allow Lambda to pass the Textract service role) |
| Textract Service Role | A dedicated IAM role that Textract can assume to publish job completion notifications to your SNS topic. Textract requires this; it cannot use the Lambda execution role. |
| BAA | AWS BAA signed. Intake forms contain extensive PHI: demographics, SSNs, medical history, medications, insurance details. This is not optional. |
| Encryption | S3: SSE-KMS with a customer-managed key. DynamoDB: encryption at rest enabled (default). Lambda CloudWatch log groups: configure KMS encryption (Lambda does not do this automatically; intake form logs can contain demographics and medical history). All API calls over TLS. |
| DynamoDB PITR | Enable DynamoDB Point-in-Time Recovery (PITR) for PHI tables; it provides continuous backup and supports disaster recovery and incident response. |
| VPC | Production: both Lambdas in a VPC with VPC endpoints for S3, Textract, DynamoDB, SNS, and CloudWatch Logs. The Logs endpoint is easy to forget: without it, Lambda silently drops all log output. No traffic to these services should cross the public internet. Enable VPC Flow Logs for network-level audit trail (CloudTrail covers API calls; Flow Logs cover network traffic, completing the HIPAA audit picture). |
| CloudTrail | Enabled for all Textract, S3, and DynamoDB API calls. Intake forms are HIPAA-covered documents; the audit trail is a compliance requirement. |
| Sample Data | Blank form templates from EHR vendors, filled with synthetic patient data. CMS publishes the CMS-1500 form for layout reference. Never use real PHI in development. |
| Cost Estimate | Textract async analysis (FORMS + TABLES): $0.065 per page ($0.05 forms + $0.015 tables). A 3-page intake form costs about $0.20. Lambda and DynamoDB costs are negligible at this scale. The default Textract StartDocumentAnalysis concurrent job quota is 25 in most regions; file an AWS Support quota increase request before go-live for high-volume deployments. |
Ingredients
| AWS Service | Role |
|---|---|
| Amazon Textract | Async multi-page document analysis: extracts key-value pairs (FORMS), tables (TABLES), and selection elements (checkboxes) |
| Amazon S3 | Stores incoming scanned forms; encrypted at rest with KMS |
| AWS Lambda (intake-start) | Triggered by S3 upload; submits the Textract async job and exits |
| AWS Lambda (intake-process) | Triggered by SNS notification; retrieves results, parses, normalizes, and stores |
| Amazon SNS | Receives Textract job completion signals; delivers them to the processing Lambda |
| Amazon DynamoDB | Stores structured extraction output; PHI encrypted at rest |
| AWS KMS | Customer-managed keys for S3 and DynamoDB encryption |
| Amazon CloudWatch | Logs, metrics, and alarms for job failures, latency, and confidence distribution |
Pseudocode Walkthrough
Reference implementations: The following AWS sample repos demonstrate the patterns used in this recipe:
amazon-textract-code-samples: General Textract samples including async document analysis patterns and table extractionamazon-textract-response-parser: Python library for navigating the Textract block response structure, useful for understanding the block graph this recipe parses manuallyguidance-for-low-code-intelligent-document-processing-on-aws: Full IDP pipeline guidance covering async ingestion, multi-feature extraction, and result storage
Walkthrough
Step 1: Start the async Textract job. This is the entry point: an intake form lands in S3, and the first Lambda wakes up to submit the extraction job. The critical difference from Recipe 1.1 is that we are not calling AnalyzeDocument (which is synchronous and single-page). We are calling StartDocumentAnalysis, which accepts a PDF or TIFF in S3, processes all pages, and returns a job ID immediately without waiting for the work to finish. We request both FORMS and TABLES feature types in a single job call. FORMS gives us key-value pairs for all the labeled fields and checkboxes. TABLES gives us row-and-column structure for medication lists and history grids. We also provide the SNS topic and Textract service role so that Textract can signal us when the job completes. The job ID gets stored in DynamoDB so the second Lambda can look up the context it needs when the notification arrives. If you skip the SNS setup and fall back to polling, you'll end up with a Lambda that runs for minutes burning money on GetDocumentAnalysis calls to a job that isn't done yet.
FUNCTION submit_extraction_job(bucket, key, sns_topic_arn, textract_role_arn):
// Submit the multi-page intake form to Textract for async analysis.
// Unlike Recipe 1.1's single-image synchronous call, this returns immediately
// with a job ID. The results aren't ready yet; they'll arrive via SNS.
response = call Textract.StartDocumentAnalysis with:
document_location = S3 object at bucket/key // the PDF or TIFF intake form
feature_types = ["FORMS", "TABLES"] // FORMS: key-value pairs and checkboxes
// TABLES: row/column structured grids
notification_channel = {
sns_topic_arn: sns_topic_arn, // where to publish when done
role_arn: textract_role_arn // role Textract assumes to publish to SNS
} // (Textract needs its own role; it can't use yours)
job_id = response.JobId
// Save job context to the tracking table so intake-process Lambda
// can look up the original document when the SNS notification arrives.
write to database table "textract-jobs":
job_id = job_id
bucket = bucket
key = key // path to the original form PDF in S3
submitted = current UTC timestamp
status = "PENDING"
RETURN job_id
Step 2: Receive the completion signal and retrieve all result pages. When Textract finishes, it publishes a message to your SNS topic. That message triggers the second Lambda with the job ID embedded in the notification payload. The Lambda's first job is to retrieve all the extracted blocks from Textract. This is where pagination matters. A five-page intake form generates hundreds of blocks: every detected word, every key-value pair, every table cell, every checkbox. Textract paginates the results, returning up to 1,000 blocks per API call with a NextToken when there are more. You have to loop through all pages before processing begins. If you stop at the first page, you'll have a partial document and you won't know it. The loop below is unglamorous but required.
FUNCTION retrieve_all_blocks(job_id):
// Pull all extracted blocks from Textract, following pagination until complete.
// Textract may return results across multiple response pages; we must collect them all.
all_blocks = empty list
next_token = null // null means "start from the beginning"
LOOP:
// Build the API call parameters. Include NextToken only if we have one.
params = { job_id: job_id }
IF next_token is not null:
params.next_token = next_token
response = call Textract.GetDocumentAnalysis with params
// Append this page's blocks to our running collection.
append all response.Blocks to all_blocks
// Check whether there's another page of results.
next_token = response.NextToken // null if this was the last page
IF next_token is null:
BREAK // we have everything; stop looping
// Build a lookup index: block ID -> block data.
// Nearly every parsing operation below needs to follow links between blocks by ID,
// so an O(1) lookup is much better than scanning the list each time.
block_map = build map of block.Id -> block for all blocks in all_blocks
RETURN all_blocks, block_map
Step 3: Parse key-value pairs. This step is nearly identical to Recipe 1.1's key-value parsing. Textract uses the same KEY_VALUE_SET block structure for multi-page documents as it does for single-page images. The parser walks the block list, finds blocks marked as KEY entities, follows the VALUE relationship link to the paired value block, and assembles the text from the child word blocks. The one new element here is that some value blocks will contain a SELECTION_ELEMENT child rather than text: that's a checkbox. We detect those here and hand them off to a separate structure rather than trying to stringify them. The output is two maps: one of text key-value pairs with confidence scores, and one of checkbox key-to-selection-status pairs. If this looks familiar from Recipe 1.1, it should. The field normalization step downstream is the same too.
FUNCTION parse_forms(all_blocks, block_map):
text_key_values = empty map // label -> { value text, confidence }
checkbox_fields = empty map // label -> true/false (selected/not selected)
FOR each block in all_blocks:
// Only process KEY blocks: the label side of each field pair.
IF block.BlockType is not "KEY_VALUE_SET":
CONTINUE
IF "KEY" is not in block.EntityTypes:
CONTINUE
// Assemble the label text from this key block's child word blocks.
key_text = concatenate text from CHILD relationships of block using block_map
// Follow the VALUE relationship to find the paired value block.
value_block = follow block's VALUE relationship using block_map
IF value_block is null:
CONTINUE // orphan key with no paired value; skip it
// Check whether the value contains a SELECTION_ELEMENT (checkbox or radio button).
selection_child = find child of value_block with BlockType "SELECTION_ELEMENT"
IF selection_child exists:
// This is a checkbox field. Record its checked/unchecked state.
// SelectionStatus will be "SELECTED" or "NOT_SELECTED".
// Note: for DynamoDB storage, you may want to keep the string value
// rather than converting to boolean, since booleans lose the original
// Textract status and make flagged-field reporting less descriptive.
checkbox_fields[key_text] = (selection_child.SelectionStatus == "SELECTED")
ELSE:
// This is a text field. Assemble the value text.
value_text = concatenate text from CHILD relationships of value_block using block_map
confidence = minimum of (block.Confidence, value_block.Confidence)
text_key_values[key_text] = { value: value_text, confidence: confidence }
RETURN text_key_values, checkbox_fields
Step 4: Parse tables. This is the step that doesn't exist in Recipe 1.1. Tables require a fundamentally different parsing approach because the structure is two-dimensional: you need row index and column index, not just label and value. Textract represents a table as a hierarchy: a TABLE block contains CELL blocks. Each CELL has RowIndex and ColumnIndex attributes that tell you exactly where in the grid it lives. Inside each CELL are WORD blocks for the cell text. The parser builds a nested map (row -> column -> text) and then converts it to a list of lists. The first row of most tables contains column headers (think: "Medication", "Dosage", "Frequency"). Preserving those headers is important for interpreting the data rows that follow. A medication row without knowing what column means "Dosage" is useless.
FUNCTION parse_tables(all_blocks, block_map):
tables = empty list // list of tables; each table is a list of rows
FOR each block in all_blocks:
IF block.BlockType is not "TABLE":
CONTINUE
// Build a nested map: row_index -> column_index -> cell_text
// We'll convert this to a list of lists once we know the dimensions.
grid = empty map
// Walk the TABLE block's CHILD relationships to find all CELL blocks.
FOR each cell_id in block's CHILD relationship IDs:
cell = block_map[cell_id]
IF cell.BlockType is not "CELL":
CONTINUE
row = cell.RowIndex // 1-indexed row position in the table
col = cell.ColumnIndex // 1-indexed column position in the table
// Assemble cell text from the CELL's WORD children.
// Some cells are empty (the patient left a row blank); empty string is correct.
cell_text = concatenate text from WORD children of cell using block_map
grid[row][col] = cell_text
// Convert the nested map to a list of lists for clean output.
// A 3-row, 4-column medication table becomes:
// [["Medication", "Dosage", "Frequency", "Prescribing Physician"],
// ["Metformin", "500mg", "Twice daily", "Dr. Chen"],
// ["Lisinopril", "10mg", "Once daily", "Dr. Chen"]]
IF grid is not empty:
max_row = maximum row index in grid
max_col = maximum column index across all rows in grid
table_rows = []
FOR r from 1 to max_row:
row_data = []
FOR c from 1 to max_col:
// Use empty string for cells that were left blank.
append grid[r][c] (or empty string if absent) to row_data
append row_data to table_rows
append table_rows to tables
RETURN tables
Step 5: Normalize fields and apply confidence gating. The same normalization logic from Recipe 1.1 applies here. Raw key labels ("First Name:", "FIRST NAME", "Patient First Name") all need to collapse to a canonical first_name. The field map for intake forms is larger than for insurance cards, covering demographics, insurance, and the medical history section. The confidence gating is the same: anything below 90% gets flagged for human review rather than written directly to the record. Handwritten fields will disproportionately populate the flagged set, which is expected. The confidence threshold for an intake form needs to be calibrated a bit more conservatively than for an insurance card, because the cost of a wrong medication name or a wrong allergy is higher than the cost of a wrong group number.
{ "first_name": ["first name", "patient first name", "fname", "given name"], "last_name": ["last name", "patient last name", "lname", "family name", "surname"], "date_of_birth": ["date of birth", "dob", "birth date", "birthdate"], "ssn": ["social security number", "ssn", "social security #"], "phone": ["phone", "phone number", "home phone", "cell phone", "telephone"], "address": ["address", "home address", "street address", "mailing address"], "member_id": ["member id", "mem id", "member #", "subscriber id", "id number"], "group_number": ["group #", "group number", "group", "grp #"], "payer_name": ["insurance company", "plan name", "payer", "carrier", "insurance"] }
CONFIDENCE_THRESHOLD = 90.0 // below this, route to human review rather than auto-accept
FUNCTION normalize_and_gate(raw_kv, checkbox_fields, tables):
// Normalize text field names to canonical labels (same logic as Recipe 1.1).
normalized = normalize_fields(raw_kv) // see Recipe 1.1 for the full implementation
// Split normalized text fields into clean (high-confidence) and flagged (needs review).
clean_fields = empty map
flagged = empty list
FOR each canonical_name, data in normalized:
IF data.confidence >= CONFIDENCE_THRESHOLD:
clean_fields[canonical_name] = data.value
ELSE:
append to flagged: {
field: canonical_name,
extracted_value: data.value,
confidence: data.confidence
}
// Checkboxes don't carry confidence scores the same way text fields do.
// Selection element detection is high-accuracy for clearly printed checkboxes.
// Flag any checkbox that Textract's own confidence falls below threshold.
clean_checkboxes = empty map
FOR each label, selection_data in checkbox_fields:
IF selection_data.confidence >= CONFIDENCE_THRESHOLD:
clean_checkboxes[label] = selection_data.selected
ELSE:
append to flagged: {
field: label,
extracted_value: selection_data.selected,
confidence: selection_data.confidence
}
RETURN clean_fields, clean_checkboxes, tables, flagged
Human Review Infrastructure
This recipe flags low-confidence fields for human review but does not implement the review workflow itself. The full human review infrastructure, including Amazon A2I integration with a private HIPAA-trained workforce, reviewer interface configuration, correction audit trails, and feedback loops, is built in Recipe 1.6. For production deployments, apply Recipe 1.6's A2I pattern to the flagged fields from this recipe. Key requirements: reviewers must be HIPAA-trained staff operating under a BAA, corrections must be traceable in the audit record, and the review queue message format should be consistent across recipes to enable a unified review interface.
Step 6: Assemble the record and store it. The final step combines clean fields, checkbox results, and table data into a unified structured record, then writes it to DynamoDB. The needs_review flag is set any time the flagged list is non-empty, making it trivial for downstream systems to identify records awaiting human attention. The SSN is stored only as last-four digits even if the form captured the full number: storing a full SSN in an operational database when last-four is sufficient for patient matching is an unnecessary liability. The page_count field is worth tracking explicitly; it's useful for quality monitoring (a three-page form that produced only two pages of blocks probably had a scan failure).
FUNCTION assemble_and_store(document_key, page_count, clean_fields, clean_checkboxes, tables, flagged):
// Construct the full structured intake record.
record = {
document_key: document_key, // S3 path of the source PDF
extracted_at: current UTC timestamp (ISO 8601), // audit trail timestamp
page_count: page_count, // pages Textract processed
needs_review: (length of flagged > 0), // true if any field is uncertain
demographics: {
first_name: clean_fields.get("first_name"),
last_name: clean_fields.get("last_name"),
date_of_birth: clean_fields.get("date_of_birth"),
ssn_last4: last 4 characters of clean_fields.get("ssn", ""), // last 4 only; never store full SSN
address: clean_fields.get("address"),
phone: clean_fields.get("phone"),
},
insurance: {
member_id: clean_fields.get("member_id"),
group_number: clean_fields.get("group_number"),
payer_name: clean_fields.get("payer_name"),
},
medical_history: {
// Checkboxes become booleans: "Diabetes" -> true, "Heart Disease" -> false, etc.
conditions: clean_checkboxes,
},
// Tables come back as lists of rows. First table is usually the medication list;
// second is usually allergies. The exact order depends on form layout.
medications: tables[0] if tables has at least 1 element else [],
allergies: tables[1] if tables has at least 2 elements else [],
// Low-confidence fields go here for the review queue.
// These will NOT be in the fields above; they're held pending human confirmation.
flagged_fields: flagged,
}
// Write the record to the database.
write record to database table "intake-extractions"
RETURN record
Curious how this looks in Python? The pseudocode above covers the concepts. If you'd like to see sample Python code that demonstrates these patterns using boto3, check out the Python Example. It walks through each step with inline comments, including the async coordination pattern, and notes on what you'd need to change for a real deployment.
Expected Results
Sample output for a 3-page intake form:
{ "document_key": "intake-forms/2026/03/01/patient-00291.pdf", "extracted_at": "2026-03-01T14:38:22Z", "page_count": 3, "needs_review": true, "demographics": { "first_name": "Maria", "last_name": "Rodriguez", "date_of_birth": "04/15/1978", "ssn_last4": "4829", "address": "1234 Elm Street, Louisville, KY 40202", "phone": "(502) 555-0147" }, "insurance": { "member_id": "HUM8294710", "group_number": "72015", "payer_name": "Humana" }, "medical_history": { "conditions": { "Diabetes": true, "Hypertension": true, "Heart Disease": false, "Cancer": false, "Asthma": true } }, "medications": [ ["Metformin", "500mg", "Twice daily", "Dr. Chen"], ["Lisinopril", "10mg", "Once daily", "Dr. Chen"], ["Albuterol", "90mcg", "As needed", "Dr. Patel"] ], "allergies": [ ["Penicillin", "Rash, hives"], ["Sulfa drugs", "Anaphylaxis"] ], "flagged_fields": [ { "field": "phone", "extracted_value": "(502) 555-O147", "confidence": 78.2, "note": "possible O/0 confusion in final digit" } ] }
Performance benchmarks:
| Metric | Typical Value |
|---|---|
| End-to-end latency (3-page form) | 8-15 seconds (async) |
| Key-value extraction accuracy (printed) | 93-98% |
| Table extraction accuracy | 90-96% (depends on table formatting) |
| Checkbox detection accuracy | 97-99% for cleanly printed checkboxes |
| Handwriting accuracy | 70-85% (highly variable; see Recipe 1.6) |
| Cost per 3-page form | ~$0.20 (Textract FORMS + TABLES at $0.065/page) + negligible Lambda/DynamoDB |
Where it struggles: Handwritten entries in printed tables, which is exactly what patients do when listing their medications in a hurry. Borderless tables where Textract infers grid structure from spatial alignment: a slightly skewed scan can shift cell assignments by one row. Forms with very dense small-font tables are also challenging: Textract may merge adjacent cells or misalign rows. And any free-text field longer than a sentence ("please describe your symptoms") produces raw text that needs additional processing before it's structured enough to use downstream.
Why This Isn't Production-Ready
The pseudocode and architecture above demonstrate the pattern. Deploying this to a real intake workflow requires addressing several gaps that are intentionally outside the scope of a cookbook recipe. These are the ones that will bite you:
Dead Letter Queue. Both Lambdas in this pipeline receive asynchronous invocations (S3 event for the first, SNS for the second). If either fails, the event retries up to three times and then silently disappears. A lost intake form is a lost patient record. Configure an SQS dead letter queue on each Lambda and set a CloudWatch alarm on the queue depth.
Textract job failure handling. The SNS notification from Textract includes a Status field. It will be SUCCEEDED or FAILED. The pseudocode calls GetDocumentAnalysis without checking. If the document is corrupted, exceeds Textract's limits, or hits an internal error, the job status will be FAILED and the API call will return an error, not results. Check the status first. On failure: log the error, move the document to a failed-documents/ S3 prefix, update the job record in DynamoDB, and fire a CloudWatch alarm.
Full SSN in flagged fields. The assemble_and_store step truncates SSN to last-four digits on the clean path. But if the SSN extraction falls below the confidence threshold, the full value lands in flagged_fields.extracted_value and gets written to DynamoDB verbatim. Add a redaction step for known PII fields (SSN, date of birth) before writing flagged records, regardless of which path they took.
Table-to-section mapping. The assemble_and_store step assigns tables[0] as medications and tables[1] as allergies based on position. Not all intake forms have the same table order. A production implementation must classify tables by header content (look for column headers like "Medication Name" or "Allergy"), not by position. Positional assignment will silently produce wrong data on forms with a different layout.
Lambda timeout. The default Lambda timeout is 3 seconds. The processing Lambda runs a pagination loop, two full parsing passes, normalization, and a DynamoDB write. For a complex 10-page form, this easily takes 15-30 seconds. Set the timeout to at least 60 seconds and tune based on your p99 processing time.
Idempotency. SNS delivers at least once, not exactly once. The processing Lambda can be invoked twice for the same document. Use DynamoDB conditional writes (check whether a record with the same document_key exists before writing) to prevent duplicate or partially-overwritten records.
Variations and Extensions
Multi-language intake forms. Practices serving diverse populations often provide intake forms in Spanish, Vietnamese, Mandarin, and other languages. Textract supports printed text extraction across a broad set of languages. After extraction, add an Amazon Translate call to normalize all output to English before normalization and storage. The translation step adds latency (roughly 1-2 seconds for a typical form) and a small cost, but it means your downstream systems see a consistent language regardless of which form variant the patient used.
Consent and signature tracking. Intake forms include signature blocks for consent to treatment, financial responsibility, and HIPAA acknowledgment. Textract's SIGNATURE block type detects whether a signature is present in a given region. Wrap a simple check around the signature fields and log consent status (signed/unsigned/not present) with a timestamp for each document. This gives compliance teams an auditable consent record without manual review.
EHR integration via FHIR. The structured output from this recipe maps naturally to FHIR R4 resources: the demographics section to Patient, the insurance section to Coverage, the medical history checkboxes to Condition, and the medication table to MedicationStatement. Build a transform layer that converts the DynamoDB record to FHIR bundles and POST them to your FHIR server (Amazon HealthLake or a third-party implementation). This closes the loop from paper form to fully standards-compliant digital record without any manual EHR data entry.
Additional Resources
AWS Documentation:
- Amazon Textract Async Operations
- Amazon Textract AnalyzeDocument vs StartDocumentAnalysis
- Amazon Textract Tables Feature
- Amazon Textract Selection Elements (Checkboxes)
- Amazon Textract Pricing
- AWS HIPAA Eligible Services
- FHIR R4 Patient Resource
- Amazon HealthLake
AWS Sample Repos:
amazon-textract-and-comprehend-medical-document-processing: Workshop for building a medical document processing pipeline with Textract and Comprehend Medical, including PDF extraction and clinical entity recognitionaws-ai-intelligent-document-processing: Comprehensive IDP solutions including document classification, multi-page extraction, and A2I human review integrationamazon-textract-textractor: Python SDK wrapper for Textract that simplifies table extraction, form parsing, and response visualization (installable via pip)amazon-textract-idp-cdk-constructs: CDK constructs for building Textract IDP pipelines, including async processing and Step Functions orchestration
AWS Solutions and Blogs:
- Guidance for Intelligent Document Processing on AWS: Reference architecture for classifying, extracting, and enriching documents at scale
- Enhanced Document Understanding on AWS: Deployable solution for document classification, extraction, and search
- Intelligent Healthcare Forms Analysis with Amazon Bedrock: Healthcare-specific forms processing using foundation models
- Processing PDF Documents with a Human Loop Using Amazon Textract and Amazon A2I: Multi-page PDF processing with human review for low-confidence extractions
Estimated Implementation Time
| Tier | Timeframe | What You Get |
|---|---|---|
| Basic (proof of concept) | 2-3 days | S3 trigger, Textract async job, basic field parsing, DynamoDB storage. Enough to demo on a single form template. |
| Production-ready | 2-3 weeks | Dead letter queues, error handling, idempotency, VPC endpoints, CloudWatch alarms, multi-template support, confidence gating with review queue integration. |
| With variations | 4-6 weeks | Add multi-language support, consent/signature tracking, FHIR output transform, and EHR integration testing. |
โ Main Recipe 1.2 ยท Python Example ยท Chapter Preface