HIPAA Patient Appointment Reminder
Extract patient data from scanned docs and send HIPAA-aware reminders.
Introduction
30 Second Summary
Medical clinics lose thousands of dollars every month when patients forget their appointments. Most clinics still rely on front-desk staff calling patients one by one, which is slow, expensive, and easy to skip when the day gets busy.
In this project, you will build a Python application that reads a scanned appointment schedule using AI-powered OCR, then automatically sends text messages and phone calls to remind each patient of their upcoming visit.
What You'll Build
Run a single command and watch your script read a scanned clinic schedule, pull out every patient's details, then text and call each one with a personalized appointment reminder.
By the end of this project, you'll have:
- A document processing pipeline that uploads a scanned schedule to encrypted cloud storage and extracts every line of text using AWS Textract.
- A regex-based parser that transforms messy OCR output into clean, structured appointment records with patient names, phone numbers, dates, and times.
- Automated SMS and voice call notifications via Twilio that follow HIPAA-safe messaging patterns, keeping protected health information out of text messages while speaking full details over the phone.
- Secret Mission: Add appointment confirmation handling where patients reply YES or NO to the SMS, and your script checks for their responses.
Are there any prerequisites?
You'll need an AWS account with the CLI configured, Python installed, and VS Code installed.
You do not need a Twilio account yet. You will create a free trial account (no credit card required) as part of the project setup.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal before any hands-on work begins.
Set Up Your Python Project and Twilio Account
To send appointment reminders, you need two things: a way to read scanned documents (AWS Textract) and a way to contact patients (Twilio).
In this step, you'll create a Python project with the right packages installed, sign up for a free Twilio account, and store your credentials safely in a .env file.
In this step, get ready to:
- Create a project folder with Python packages for AWS and Twilio.
- Set up a Twilio trial account and claim a phone number.
- Store your credentials in a .env file.
Create your project folder and install packages
Your project needs a dedicated folder and a virtual environment to keep its packages separate from the rest of your system.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir clinic-reminders
cd clinic-reminders
- Create a Python virtual environment by running:
python -m venv venv
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
Linux
source venv/bin/activate
You should see (venv) appear at the start of your terminal prompt.
What is a virtual environment?
A virtual environment is an isolated Python workspace. Packages you install here won't interfere with other projects on your machine.
- Install the required Python packages by running:
pip install boto3 twilio python-dotenv
You should see "Successfully installed" messages for all three packages in the terminal output.
What do these packages do?
- boto3 is the official AWS SDK for Python. You'll use it to talk to S3 and Textract.
- twilio is the official Twilio SDK. You'll use it to send SMS messages and make phone calls.
- python-dotenv loads environment variables from a .env file so you never hardcode credentials.
🙋♀️ Seeing a pip error?
- Make sure you activated your virtual environment first. You should see (venv) in your prompt.
- If you see "command not found" for pip, try pip3 instead.
Ask for help: Why is pip not working in my virtual environment?
Sign up for a Twilio free trial account
Twilio is a communication API that lets you send SMS messages and make phone calls from code. The free trial gives you a phone number and enough credits to test your reminder system.
- Open twilio.com/try-twilio in your browser.
- Fill in your name, email, and a password, then verify your email address.
- When prompted to select a plan, click Continue with trial.
- Verify your personal phone number when prompted. Twilio sends a code to your phone via SMS.
- Enter the verification code to confirm your number.
Why verify your phone number?
On a Twilio trial account, you can only send messages and make calls to verified phone numbers. The number you sign up with is automatically verified as a recipient.
No credit card is required. The trial includes 100 SMS messages and 75 voice minutes.
- Once in the Twilio Console, get a phone number by navigating to Products & Services then Numbers & senders then Overview.
- Click Set up a new phone number. Twilio assigns you a number automatically.
- Copy the phone number that Twilio assigns (it starts with +1) and save it somewhere temporary.
Now collect your API credentials from the Console.
- Locate the Account Info pane on the Console Dashboard.
- Copy your Account SID (it starts with AC) and save it.
- Click show next to the Auth Token to reveal it, then copy and save it.
Keep your Auth Token private
Your Auth Token is a secret. Never commit it to version control or share it publicly. You'll store it in a .env file that stays local to your machine.
Create your .env file
Instead of pasting credentials directly into your code, you'll store them in a .env file. The python-dotenv package loads these values at runtime.
- In VS Code, open your clinic-reminders folder by clicking File in the top menu bar.
- Click Open Folder.
- Select the clinic-reminders folder on your Desktop, then click Open.
- Create a new file by clicking File then New File.
- Save it immediately as .env (press Cmd+S on macOS or Ctrl+S on Windows and type .env as the filename).
- Paste the following into your .env file, replacing the placeholder values with your actual Twilio credentials:
TWILIO_ACCOUNT_SID=your-account-sid-here
TWILIO_AUTH_TOKEN=your-auth-token-here
TWILIO_PHONE_NUMBER=+1234567890
S3_BUCKET=your-clinic-appointments-bucket
DOCUMENT_KEY=appointment-schedule.png
What do these variables mean?
- TWILIO_ACCOUNT_SID identifies your Twilio account.
- TWILIO_AUTH_TOKEN authenticates your API requests.
- TWILIO_PHONE_NUMBER is the Twilio number that sends your messages and makes calls.
- S3_BUCKET and DOCUMENT_KEY point to the appointment schedule you'll upload in the next step. Leave the defaults for now.
- Replace your-account-sid-here with the Account SID you copied from Twilio.
- Replace your-auth-token-here with the Auth Token you copied.
- Replace +1234567890 with your Twilio phone number (keep the + and country code).
- Save the file.
✔️ Awesome, I've got everything!
Great. Double check you've saved your .env file with your real credentials filled in.
ⓧ I'd like to double check the full code
Your .env file should look like this (with your own values replacing the placeholders):
TWILIO_ACCOUNT_SID=your-account-sid-here
TWILIO_AUTH_TOKEN=your-auth-token-here
TWILIO_PHONE_NUMBER=+1234567890
S3_BUCKET=your-clinic-appointments-bucket
DOCUMENT_KEY=appointment-schedule.png
Now confirm everything is wired up correctly.
- Switch back to your terminal (make sure you're still in the clinic-reminders folder with (venv) active).
Before you run the next command, what do you think will happen? Will both imports succeed, or will one fail?
- Verify that both packages import correctly by running:
python -c "import boto3; from twilio.rest import Client; print('Ready!')"
You should see Ready! printed to the terminal. Both AWS and Twilio SDKs are installed and importable.
Seeing ModuleNotFoundError?
- Check that (venv) is showing in your terminal prompt. If not, re-activate the virtual environment.
- Run pip list to confirm boto3 and twilio appear in the installed packages list.
- If they are missing, run the pip install command again.
Ask for help: Why am I getting ModuleNotFoundError for boto3 or twilio?
Your development environment is ready to go. In the next step, you'll establish HIPAA compliance on your AWS account and create an encrypted S3 bucket to store patient documents.
Establish HIPAA Compliance and Create Encrypted Storage
Your development environment is ready with AWS and Twilio packages installed. Now it is time to lay the compliance foundation before any patient data touches your system.
Before storing or processing any patient health information, you need a signed Business Associate Addendum with AWS and an encrypted S3 bucket. Without this, storing PHI on AWS violates HIPAA.
In this step, get ready to:
- Accept the AWS Business Associate Addendum via AWS Artifact.
- Create an encrypted S3 bucket with all public access blocked.
- Upload a sample appointment schedule image and verify your HIPAA configuration.
Accept the Business Associate Addendum in AWS Artifact
A Business Associate Addendum (BAA) is a legal agreement between you and AWS that permits you to store Protected Health Information (PHI) on their platform. Without it, you cannot legally use AWS services to process patient data.
- Sign in to the AWS Management Console at https://console.aws.amazon.com.
- In the search bar at the top, type Artifact and select AWS Artifact from the results.
- Select the Account agreements tab.
- Locate the Business Associate Addendum (BAA) in the list of available agreements.
- Review the terms of the agreement.
- Check the agreement box, then click Accept agreement.
You should see the BAA status change to Active in the Account agreements tab.
What is a Business Associate Addendum?
Under HIPAA, any third-party service that handles PHI on your behalf is a Business Associate. The BAA legally obligates AWS to protect that data according to HIPAA standards.
Once accepted, your account is designated as a HIPAA Account. Only HIPAA-eligible services (like S3 and Textract) may be used to store or transmit PHI.
🙋♀️ Don't see the BAA listed?
Make sure you are in the Account agreements tab, not the Organization agreements tab. The BAA only appears under Account agreements for individual accounts.
help me find the Business Associate Addendum in AWS Artifact
Create an encrypted S3 bucket and upload your document
Now you need a secure place to store the appointment schedule. The BAA requires encryption of PHI at rest and in transit. S3 handles in-transit encryption via HTTPS automatically. For at-rest encryption, you will enable SSE-S3.
- In the AWS Console search bar, type S3 and select S3 from the results.
- In the top navigation bar, confirm the region is set to US East (N. Virginia) us-east-1.
- In the left navigation pane, choose General purpose buckets.
- Click Create bucket.
- In the Bucket name field, enter a globally unique name like clinic-appointments-yourname (replace with your own identifier).
- Under Block Public Access settings for this bucket, confirm that Block all public access is checked. This is the default.
- Under Default encryption, confirm that Encryption type is set to Server-side encryption with Amazon S3 managed keys (SSE-S3). This is the default for all new buckets.
- Click Create bucket at the bottom of the page.
You should see your new bucket appear in the bucket list.
Why SSE-S3 encryption?
SSE-S3 uses AES-256 encryption managed entirely by AWS. Every object uploaded to the bucket is automatically encrypted at rest at no additional cost.
Combined with HTTPS (encryption in transit), this satisfies the BAA requirement that PHI must be encrypted both at rest and in transit.
Now upload your sample appointment schedule. You need a PNG or JPG image of appointment data that Textract can read.
- Create a sample appointment schedule image. Open any text editor or document app, type a few appointment entries (see the example format below), and take a screenshot or export as PNG.
- Name the file appointment-schedule.png.
Use a format like this for your appointment entries:
Patient Appointment Schedule
John Smith
555-123-4567
7/15/2026 10:30 AM
Jane Doe
555-987-6543
7/15/2026 2:00 PM
Bob Johnson
555-456-7890
7/16/2026 9:00 AM
What format does the parser expect?
The parser you build in a later step looks for phone numbers in the format xxx-xxx-xxxx, times like 10:30 AM, and dates like 7/15/2026. Include all three for each patient entry so Textract can extract them.
- In the S3 console, click on your newly created bucket name to open it.
- Click Upload.
- Click Add files and select your appointment-schedule.png file.
- Click Upload.
You should see a green success banner and your file listed as an object in the bucket.
Update your .env if needed
If you used a different bucket name than what is in your .env file, update the S3_BUCKET value to match your actual bucket name. The DOCUMENT_KEY value should remain appointment-schedule.png.
🙋♀️ Upload failing?
Confirm your file is under 10 MB and in PNG or JPG format. Textract supports JPEG, PNG, PDF, and TIFF for synchronous operations.
help me troubleshoot uploading a file to my S3 bucket
Verify your HIPAA configuration
Before moving on, confirm that all three compliance pillars are in place: the BAA is active, encryption is enabled, and public access is blocked.
- Return to AWS Artifact and select the Account agreements tab.
- Confirm the BAA status shows Active.
Before you run the next command, what do you think the encryption output will show for your bucket?
- Verify bucket encryption by running this command in your terminal:
aws s3api get-bucket-encryption --bucket [[S3_BUCKET="your-clinic-appointments-bucket"]]
You should see JSON output confirming SSE-S3 is active:
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}
]
}
}
What does AES256 mean here?
AES256 is the algorithm indicator for SSE-S3 (server-side encryption with Amazon S3 managed keys). It means every object in this bucket is encrypted using 256-bit Advanced Encryption Standard. AWS manages the keys automatically.
- Confirm public access is blocked by running:
aws s3api get-public-access-block --bucket [[S3_BUCKET="your-clinic-appointments-bucket"]]
You should see all four settings set to true:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
Seeing 'NoSuchBucket' or access errors?
Double-check that your bucket name matches exactly what you created. Bucket names are case-sensitive and globally unique.
If you see an access denied error, confirm your AWS CLI is configured with credentials that have S3 permissions.
help me fix access errors when running aws s3api commands
Your compliance foundation is solid. You have a signed BAA designating your account for HIPAA workloads, an encrypted bucket with no public access, and a sample document ready for processing. Next up, you will use AWS Textract to extract the text from that scanned appointment schedule.
Extract Text from the Appointment Schedule
Your HIPAA-compliant storage is in place, with an encrypted S3 bucket holding your scanned appointment schedule. Now you need a way to actually read what's on that document programmatically.
AWS Textract uses machine learning to detect and extract text from images. You point it at your S3 object, and it returns every line of text it finds, with no templates or pre-configuration needed.
In this step, get ready to:
- Write a Python script that connects to AWS Textract and calls the document text detection API.
- Filter the response for detected lines and their confidence scores.
- Run the script and observe the raw, unstructured text extracted from your appointment schedule.
Set up the script with imports and configuration
Your script needs to load credentials from .env, connect to AWS services via boto3, and eventually use the Twilio SDK for notifications. Setting up all imports and configuration now means the script is ready to grow in later steps.
- Create a new file called appointment_reminder.py in your project folder.
- Add the following imports and configuration by pasting this code:
import os
import re
import time
import boto3
from twilio.rest import Client
from dotenv import load_dotenv
load_dotenv()
# AWS Configuration
AWS_REGION = "us-east-1"
S3_BUCKET = os.environ.get("S3_BUCKET", "your-clinic-appointments-bucket")
DOCUMENT_KEY = os.environ.get("DOCUMENT_KEY", "appointment-schedule.png")
# Twilio Configuration
TWILIO_ACCOUNT_SID = os.environ["TWILIO_ACCOUNT_SID"]
TWILIO_AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
TWILIO_PHONE_NUMBER = os.environ["TWILIO_PHONE_NUMBER"]
# Clinic Configuration
CLINIC_PHONE = "555-0100"
What does this code do?
load_dotenv() reads your .env file and loads the values into environment variables. This keeps credentials out of your source code.
The AWS configuration reads S3_BUCKET and DOCUMENT_KEY from your environment with fallback defaults. The Twilio credentials use os.environ["..."] (no fallback) so the script fails fast if they are missing.
- Save appointment_reminder.py.
- Verify the imports load correctly by running:
python -c "from appointment_reminder import S3_BUCKET; print(S3_BUCKET)"
You should see your bucket name printed (your-clinic-appointments-bucket or whatever you set in .env).
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated. You should see the environment name in parentheses in your terminal prompt. Also confirm you installed python-dotenv in the earlier step.
help me debug a ModuleNotFoundError when importing from my appointment_reminder.py script
Write the text extraction function
AWS Textract's detect_document_text API takes an S3 object reference and returns a list of Block objects. Each Block has a BlockType field. Lines of text come back as blocks with type LINE, each carrying a Text field and a Confidence score (0 to 100).
- Add the following function below your configuration section in appointment_reminder.py:
def extract_text_from_document(bucket, document_key):
"""Use AWS Textract to extract text from a document stored in S3."""
textract = boto3.client("textract", region_name=AWS_REGION)
response = textract.detect_document_text(
Document={"S3Object": {"Bucket": bucket, "Name": document_key}}
)
lines = []
for block in response["Blocks"]:
if block["BlockType"] == "LINE":
lines.append(
{"text": block["Text"], "confidence": block["Confidence"]}
)
return lines
What does this function do?
It creates a Textract client, then calls detect_document_text pointing at your encrypted S3 document. Textract responds with all detected blocks (pages, lines, and words).
The loop filters for LINE blocks only, extracting the text content and confidence score into a clean list of dictionaries. This gives you one entry per visible line on the scanned document.
- Save appointment_reminder.py.
Why filter for LINE instead of WORD?
Textract returns three block types for text detection: PAGE, LINE, and WORD. A LINE is a string of contiguous words on the same row. Filtering for LINE gives you complete readable phrases (like a patient name or phone number) rather than scattered individual words.
Run the extraction and observe the raw output
The function is ready. Before you run it, think about what you expect Textract to return from a scanned appointment schedule.
Do you think the output will be neatly organized into appointment records, or something less structured?
- Test the extraction function by running:
python -c "
from appointment_reminder import extract_text_from_document, S3_BUCKET, DOCUMENT_KEY
lines = extract_text_from_document(S3_BUCKET, DOCUMENT_KEY)
print(f'Extracted {len(lines)} lines of text')
print('-' * 40)
for line in lines:
print(f' [{line[chr(39)+chr(39)]confidence[chr(39)+chr(39)]}:.1f}%] {line[chr(39)+chr(39)]text[chr(39)+chr(39)]}' if False else f' [{line[\"confidence\"]:.1f}%] {line[\"text\"]}')
"
If the multi-line command above gives you trouble, you can run it interactively instead:
- Open a Python shell by running:
python
- Then run these lines one at a time:
from appointment_reminder import extract_text_from_document, S3_BUCKET, DOCUMENT_KEY
lines = extract_text_from_document(S3_BUCKET, DOCUMENT_KEY)
print(f"Extracted {len(lines)} lines of text")
for line in lines:
print(f" [{line['confidence']:.1f}%] {line['text']}")
exit()
You should see each line of text from your appointment schedule printed with its confidence score. Patient names, phone numbers, dates, and times all appear as plain text lines.
What should I see?
Your terminal prints a flat list of text lines. Something like:
Extracted 12 lines of text
----------------------------------------
[99.2%] Dr. Smith's Clinic
[98.7%] John Doe
[99.1%] 555-123-4567
[97.8%] 7/20/2026 10:00 AM
...
Notice how the output is raw and flat. Textract has no concept of what a "patient appointment" is. It simply reads every line of text it detects on the page and dumps them in reading order. There is no grouping of a name with its phone number or appointment time.
🙋♀️ Getting an AccessDeniedException?
Your IAM user needs the textract:DetectDocumentText permission. Check that your AWS CLI credentials are configured for a user or role with Textract access. Also confirm your S3 bucket is in us-east-1 (same region as the Textract client).
help me fix an AccessDeniedException when calling AWS Textract detect_document_text
💡 Why is the output unstructured?
Textract is an OCR service. It detects text on a page but does not understand the meaning or relationships between lines. A phone number on one line has no connection to the name on the line above it. You will need to write parsing logic to group these raw lines into usable appointment records.
✔️ Awesome, I've got everything!
Great. Double check you have saved appointment_reminder.py with both the configuration section and the extract_text_from_document function.
ⓧ I'd like to double check the full code
Here is the complete appointment_reminder.py at the end of this step:
import os
import re
import time
import boto3
from twilio.rest import Client
from dotenv import load_dotenv
load_dotenv()
# AWS Configuration
AWS_REGION = "us-east-1"
S3_BUCKET = os.environ.get("S3_BUCKET", "your-clinic-appointments-bucket")
DOCUMENT_KEY = os.environ.get("DOCUMENT_KEY", "appointment-schedule.png")
# Twilio Configuration
TWILIO_ACCOUNT_SID = os.environ["TWILIO_ACCOUNT_SID"]
TWILIO_AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
TWILIO_PHONE_NUMBER = os.environ["TWILIO_PHONE_NUMBER"]
# Clinic Configuration
CLINIC_PHONE = "555-0100"
And below the configuration, the extraction function:
def extract_text_from_document(bucket, document_key):
"""Use AWS Textract to extract text from a document stored in S3."""
textract = boto3.client("textract", region_name=AWS_REGION)
response = textract.detect_document_text(
Document={"S3Object": {"Bucket": bucket, "Name": document_key}}
)
lines = []
for block in response["Blocks"]:
if block["BlockType"] == "LINE":
lines.append(
{"text": block["Text"], "confidence": block["Confidence"]}
)
return lines
You now have raw text extracted from a scanned medical document. Next up, you will write parsing logic that transforms this flat list of text lines into structured appointment records with names, phone numbers, dates, and times.
Parse Raw Text into Structured Appointments
In the previous step, you watched Textract pull raw text from your scanned appointment schedule. The result was a flat list of text lines with no organization.
You cannot send appointment reminders without knowing which phone number belongs to which patient and what time their appointment is. In this step, you will write parsing logic that transforms that messy output into clean, structured records.
In this step, get ready to:
- Define regex patterns that detect phone numbers, dates, and times in raw text.
- Build a parsing function that groups text lines into structured appointment dictionaries.
- Test the parser against your extracted text and confirm each appointment has all required fields.
Define regex patterns and start the parsing loop
Your appointment schedule follows a predictable format. Each appointment contains a patient name, a phone number in xxx-xxx-xxxx format, a date like MM/DD/YYYY, and a time like 10:30 AM. Regex patterns let you identify each of these data types within unstructured text.
- In appointment_reminder.py, find the end of the extract_text_from_document() function, then add the following code below it:
def parse_appointments(lines):
"""Parse raw text lines into structured appointment records."""
appointments = []
phone_pattern = re.compile(r"\d{3}[-.\s]?\d{3}[-.\s]?\d{4}")
time_pattern = re.compile(
r"\d{1,2}:\d{2}\s*(?:AM|PM|am|pm)", re.IGNORECASE
)
date_pattern = re.compile(r"\d{1,2}/\d{1,2}/\d{2,4}")
current_appointment = {}
for line in lines:
text = line["text"]
phone_match = phone_pattern.search(text)
if phone_match:
current_appointment["phone"] = phone_match.group()
time_match = time_pattern.search(text)
if time_match:
current_appointment["time"] = time_match.group()
date_match = date_pattern.search(text)
if date_match:
current_appointment["date"] = date_match.group()
What does this code do?
- phone_pattern matches 10-digit phone numbers with optional dashes, dots, or spaces between groups (e.g. 555-123-4567).
- time_pattern catches times in 12-hour format with AM/PM (e.g. 2:30 PM).
- date_pattern matches dates in slash-separated format (e.g. 7/15/2026).
- The for loop tests each Textract line against the three patterns, storing any match in current_appointment.
Complete the name detection and record assembly
The patterns detect phone numbers, dates, and times. But patient names have no predictable format, so the function treats any unmatched line as a name. Once all four fields are found, the record is complete.
- Directly below the date matching block you just added (still inside the for loop), paste this code to handle names and assemble records:
if (
not phone_match
and not time_match
and not date_match
and text.strip()
):
if "name" not in current_appointment:
current_appointment["name"] = text.strip()
if all(
key in current_appointment
for key in ["name", "phone", "date", "time"]
):
appointments.append(current_appointment)
current_appointment = {}
return appointments
What does this code do?
- Lines that do not match any pattern are treated as patient names. Only the first unmatched line per record is used.
- Once all four keys (name, phone, date, time) are present, the record is complete and gets appended to the results list.
- After appending, current_appointment resets to an empty dictionary so the next record starts fresh.
- Save appointment_reminder.py.
Seeing indentation errors?
The entire for loop must be indented one level inside parse_appointments(). Make sure every line of the loop starts with exactly 4 spaces relative to the function definition.
help me fix indentation errors in my parse_appointments function
Test the parser against your extracted text
The function is complete, but you have not seen it work yet. You need a test block that calls both the extraction and parsing functions, then prints the structured output.
- At the very bottom of appointment_reminder.py, add this test block:
if __name__ == "__main__":
print("Step 1: Extracting text from appointment schedule...")
lines = extract_text_from_document(S3_BUCKET, DOCUMENT_KEY)
print(f" Extracted {len(lines)} lines of text.\n")
print("Raw extracted text:")
print("-" * 40)
for line in lines:
print(f" [{line['confidence']:.1f}%] {line['text']}")
print("-" * 40)
print("\nStep 2: Parsing appointments from raw text...")
appointments = parse_appointments(lines)
print(f" Found {len(appointments)} appointments.\n")
for appt in appointments:
print(
f" {appt['name']} | {appt['phone']} | "
f"{appt['date']} {appt['time']}"
)
What does this test block do?
This calls both functions in sequence. First it extracts raw text from your S3 document. Then it feeds those lines into your new parser. The final loop prints each appointment as a clean, pipe-separated row so you can quickly confirm the data looks correct.
You will replace this test block with a full main() function in the next step.
- Save appointment_reminder.py.
Before you run this, what do you expect to see? Will the parser find complete appointments, or will the raw lines not match the expected patterns?
- Run the script by executing this command:
python appointment_reminder.py
You should see two sections of output. First, the raw extracted text with confidence scores. Then, a parsed summary showing each appointment as a clean record with name, phone, date, and time fields separated by pipes.
What should I see?
The output will look something like this:
Step 1: Extracting text from appointment schedule...
Extracted 12 lines of text.
Raw extracted text:
----------------------------------------
[99.2%] John Smith
[98.7%] 555-123-4567
[99.1%] 7/15/2026 10:30 AM
----------------------------------------
Step 2: Parsing appointments from raw text...
Found 3 appointments.
John Smith | 555-123-4567 | 7/15/2026 | 10:30 AM
The exact names, numbers, and count depend on your appointment schedule image. The important thing is that the parser grouped the raw lines into complete records.
🙋♀️ Found 0 appointments?
Check the raw extracted text section of the output. If the phone numbers in your document use a format like (555) 123-4567 instead of 555-123-4567, the regex will not match them. Adjust your sample document to use the expected format, or update the regex pattern.
Also confirm your document has dates in MM/DD/YYYY format and times with AM/PM. All four fields must be present for a record to be captured.
help me debug why parse_appointments returns 0 results
✔️ Awesome, I've got everything!
Great. Double check you have saved appointment_reminder.py and your parsed appointments are printing correctly.
ⓧ I'd like to double check the full code
Your appointment_reminder.py should contain these sections in order. Scroll up and confirm each one is present:
- Imports (os, re, time, boto3, Client, load_dotenv) and load_dotenv() call.
- AWS, Twilio, and Clinic configuration constants.
- extract_text_from_document() function that calls Textract and returns a list of line dicts.
- parse_appointments() function with three regex patterns and a for loop that builds appointment dictionaries.
- A test block (if __name__ == "__main__":) that extracts text, parses it, and prints the results.
You now have structured appointment data ready to use. Next up, you will use this data to send HIPAA-aware SMS reminders and automated voice calls to each patient.
Send HIPAA-Aware Patient Notifications
Your script can now extract text from a scanned appointment schedule and parse it into clean, structured records. The missing piece is actually reaching the patients.
Twilio lets you send SMS messages and make voice calls from Python. In a HIPAA-aware design, you treat these two channels differently. SMS messages can appear on lock screens, so they must never contain patient names or medical details. Voice calls are private because only the recipient hears the message, so you can include appointment specifics there.
In this step, get ready to:
- Write a HIPAA-safe SMS function that reminds patients without revealing any personal health information.
- Write a voice call function that speaks appointment details using text-to-speech.
- Build a notification loop that contacts every patient and prints a success/failure summary.
Write the SMS notification function
SMS messages sit on lock screens and notification panels where anyone nearby can read them. A HIPAA-aware SMS never includes the patient's name, diagnosis, or appointment details.
- In appointment_reminder.py, scroll to the bottom of the file (after the parse_appointments function) and add the following function:
def send_sms_reminder(client, appointment):
"""Send a HIPAA-safe SMS reminder (no PHI in message body)."""
message = client.messages.create(
body=(
f"Reminder: You have an upcoming appointment. "
f"Call {CLINIC_PHONE} to confirm or reschedule."
),
from_=TWILIO_PHONE_NUMBER,
to=appointment["phone"],
)
return message.sid
What does this code do?
This function uses Twilio's client.messages.create() to send an SMS.
- The body is deliberately generic. It says "You have an upcoming appointment" without mentioning the patient's name, condition, or time.
- The from_ parameter uses your Twilio number. The trailing underscore avoids clashing with Python's from keyword.
- The function returns the message SID so you can track delivery status later.
💡 Why is SMS unsafe for health details?
Under HIPAA, Protected Health Information (PHI) includes a patient's name combined with appointment dates or medical details. SMS messages are stored in plaintext on carrier networks, appear on lock screens, and can be read by anyone with physical access to the device.
A compliant SMS reveals only that an appointment exists. The patient must call the clinic for specifics.
Write the voice call function
Voice calls are more private because only the person who answers hears the message. This means you can safely include the patient's name and appointment time in the spoken content.
- Below the send_sms_reminder function you just added, paste this function:
def make_voice_call(client, appointment):
"""Make a voice call with appointment details using TwiML."""
twiml_message = (
f"<Response><Say voice=\"alice\">"
f"Hello {appointment['name']}. This is a reminder from your "
f"medical clinic. You have an appointment scheduled on "
f"{appointment['date']} at {appointment['time']}. "
f"Please call {CLINIC_PHONE} if you need to reschedule. "
f"Thank you and have a great day.</Say></Response>"
)
call = client.calls.create(
twiml=twiml_message,
to=appointment["phone"],
from_=TWILIO_PHONE_NUMBER,
)
return call.sid
What does this code do?
This function uses Twilio's client.calls.create() with a twiml parameter instead of a webhook URL. The twiml parameter lets you pass TwiML directly as a string (up to 4000 characters) without running a web server.
- The <Response><Say voice="alice"> tags tell Twilio to read the text aloud using a natural-sounding voice.
- Patient name, date, and time are all included in the spoken message because only the call recipient hears them.
- The function returns the call SID for tracking.
- Save appointment_reminder.py.
- Confirm there are no syntax errors by running this command:
python -c "from appointment_reminder import send_sms_reminder, make_voice_call; print('Notification functions ready!')"
You should see Notification functions ready! printed with no errors.
Seeing a syntax error?
- Check that the f-string quotes in twiml_message use escaped double quotes (\"alice\") inside the outer string.
- Make sure the from_ parameter has a trailing underscore. Without it, Python treats from as a reserved keyword.
- Confirm both functions are indented at the top level (not nested inside another function).
help me fix a syntax error in my Twilio notification functions
💡 HIPAA on Twilio vs AWS
AWS lets you accept a Business Associate Addendum instantly through the console at no cost. Twilio is different. A formal HIPAA BAA with Twilio requires their Security Edition or Enterprise Edition (paid plans) plus a BAA signed with a Twilio account manager.
In this project, you implement HIPAA-aware coding patterns (no PHI in SMS) on Twilio's free trial. A production clinic system would need the paid Twilio edition and a signed BAA before handling real patient data.
Build the notification loop and run the full pipeline
You now have two notification functions ready. The next piece is an orchestrator that loops through every appointment, calls both functions, and tracks which notifications succeeded or failed.
- Below the make_voice_call function, add the notification loop:
def notify_patients(appointments):
"""Send SMS and voice call reminders to all patients."""
client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
results = []
for appointment in appointments:
result = {
"name": appointment["name"],
"phone": appointment["phone"],
"sms_status": "pending",
"call_status": "pending",
}
try:
sms_sid = send_sms_reminder(client, appointment)
result["sms_status"] = f"sent ({sms_sid})"
except Exception as e:
result["sms_status"] = f"failed ({str(e)})"
try:
call_sid = make_voice_call(client, appointment)
result["call_status"] = f"sent ({call_sid})"
except Exception as e:
result["call_status"] = f"failed ({str(e)})"
results.append(result)
return results
What does this code do?
- Creates a single Client instance using your Twilio credentials. Reusing one client across all calls avoids re-authenticating for each patient.
- Each appointment gets a result dictionary that starts with "pending" status for both SMS and call.
- The try/except blocks catch failures per-patient so one bad phone number does not stop the entire batch.
- Below notify_patients, add the summary printer:
def print_summary(results):
"""Print a formatted summary of all notification results."""
print("\n" + "=" * 60)
print("APPOINTMENT REMINDER SUMMARY")
print("=" * 60)
for result in results:
print(f"\nPatient: {result['name']}")
print(f" Phone: {result['phone']}")
print(f" SMS: {result['sms_status']}")
print(f" Call: {result['call_status']}")
print("\n" + "=" * 60)
sent_count = sum(
1 for r in results if "sent" in r["sms_status"]
)
print(
f"Total: {len(results)} patients | "
f"{sent_count} notified successfully"
)
print("=" * 60)
What does this code do?
This function formats the notification results into a readable table. It counts how many patients were successfully notified by checking for "sent" in their SMS status.
- Save appointment_reminder.py and confirm the new functions import cleanly:
python -c "from appointment_reminder import notify_patients, print_summary; print('Pipeline functions ready!')"
You should see Pipeline functions ready! with no errors.
- Now add the main function at the bottom of the file to orchestrate the full pipeline:
def main():
print("Step 1: Extracting text from appointment schedule...")
lines = extract_text_from_document(S3_BUCKET, DOCUMENT_KEY)
print(f" Extracted {len(lines)} lines of text.\n")
print("Raw extracted text:")
print("-" * 40)
for line in lines:
print(f" [{line['confidence']:.1f}%] {line['text']}")
print("-" * 40)
print("\nStep 2: Parsing appointments from raw text...")
appointments = parse_appointments(lines)
print(f" Found {len(appointments)} appointments.\n")
for appt in appointments:
print(
f" {appt['name']} | {appt['phone']} | "
f"{appt['date']} {appt['time']}"
)
if not appointments:
print("No appointments found. Check your document format.")
return
What does this code do?
- Calls your extraction function to pull raw text lines from the scanned document in S3.
- Prints the raw text with confidence scores so you can see what Textract detected.
- Passes the raw lines through your parser and prints each structured appointment record.
- Exits early with a helpful message if no appointments were found, preventing the script from sending notifications to nobody.
- Still inside the main function, add the notification stage directly after the return statement (at the same indentation level as the if not appointments block):
print("\nStep 3: Sending HIPAA-aware notifications...")
results = notify_patients(appointments)
print_summary(results)
if __name__ == "__main__":
main()
What does this code do?
- The indented lines call notify_patients and print_summary to send reminders and display results.
- The if __name__ == "__main__" guard ensures main() only runs when you execute the script directly, not when importing functions.
💡 Twilio trial only reaches verified numbers
On a Twilio free trial, SMS and voice calls can only be sent to phone numbers you have verified in the Twilio Console. Your signup number is automatically verified.
Make sure your sample appointment schedule image includes your own verified phone number for at least one patient. Any other numbers in the document will show a "failed" status in the summary, which is expected behaviour on the trial.
- Save appointment_reminder.py.
Before you run the script, what do you expect to see in the terminal? Think about what the three pipeline stages will print.
- Run the full appointment reminder pipeline:
python appointment_reminder.py
You should see the raw extracted text from your schedule, followed by parsed appointment records, and finally the APPOINTMENT REMINDER SUMMARY showing SMS and call statuses.
Check your phone. You should receive an SMS with a generic reminder (no patient name or time) and a voice call that speaks your name and appointment details aloud.
SMS or call showing as failed?
- If you see an error mentioning an unverified number, confirm the phone number in your appointment schedule matches the verified number in your Twilio Console. Trial accounts can only message verified numbers.
- If you see an authentication error, double-check TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in your .env file match exactly what the Twilio Console shows.
- If you see "No appointments found", your parsing logic did not match the format in your image. Verify your appointment schedule image has phone numbers in the xxx-xxx-xxxx format.
help me debug why my Twilio notifications are failing
✔️ Awesome, I've got everything!
Great. Double check you have saved appointment_reminder.py with all five new functions: send_sms_reminder, make_voice_call, notify_patients, print_summary, and main.
ⓧ I'd like to double check the full code
The full file is longer than 30 lines, so confirm each section is present in order by scrolling through appointment_reminder.py:
- Imports and configuration constants at the top (AWS_REGION, S3_BUCKET, DOCUMENT_KEY, Twilio credentials, CLINIC_PHONE).
- The extract_text_from_document function (from Step 3).
- The parse_appointments function (from Step 4).
- The send_sms_reminder function.
- The make_voice_call function.
- The notify_patients function.
- The print_summary function.
- The main function followed by if __name__ == "__main__": main().
Your reminder system is live. Patients get a private phone call with their details and a discreet text that keeps their health information safe.
Secret mission
Add Appointment Confirmation Tracking
In a real clinic, sending reminders is only half the job. You also need to know whether patients actually confirmed their appointments. In this challenge, you will add reply handling that checks if patients responded YES or NO to your SMS reminders.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS S3 storage (fractions of a cent per month) and a Twilio trial account that expires automatically after 30 days with no billing.
Cost warning
Your S3 bucket stores a single image file, costing less than $0.01 per month. If you upgrade your Twilio trial to a paid account in the future, the phone number costs $1.15 per month. If you leave the trial as-is, it expires after 30 days with no charges.
Resources you used:
- S3 bucket with uploaded appointment schedule image.
- AWS BAA agreement accepted via AWS Artifact.
- Twilio trial account with a provisioned phone number.
- Local project folder with appointment_reminder.py, .env, and a Python virtual environment.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the appointment reminder system.
- Your S3 bucket costs fractions of a cent per month for the single stored image.
- Your Twilio trial will expire on its own after 30 days with no charges.
- The AWS BAA remains active at no cost until you terminate it.
- Your local project files stay on your machine and take up minimal disk space.
Pause - I'll come back to this later
Remove the S3 objects to reduce any storage cost, but keep your bucket and local files so you can pick up where you left off.
- Delete the appointment schedule image from your S3 bucket by running:
aws s3 rm s3://[[S3_BUCKET="your-clinic-appointments-bucket"]]/appointment-schedule.png
- Your Twilio trial will expire on its own after 30 days.
- Your local project files remain intact for future use.
When you return, re-upload a document to your S3 bucket to continue testing.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean up everything.
Delete your S3 bucket and all its contents:
aws s3 rb s3://[[S3_BUCKET="your-clinic-appointments-bucket"]] --force
Terminate the AWS BAA in AWS Artifact:
- Open the AWS Artifact console.
- In the navigation pane, choose Agreements.
- Choose the Account agreements tab.
- Select the BAA and choose Terminate agreement.
- Select all checkboxes to confirm termination.
- Choose Terminate. When prompted for confirmation, choose Terminate again.
Only terminate the BAA if you are done with PHI
Terminating the BAA means your AWS account will immediately cease to be a HIPAA Account. Only do this if you have removed all protected health information and will not use the account for PHI in the future. You can re-activate the BAA later if needed.
Close your Twilio trial account (optional):
- Your Twilio trial expires automatically after 30 days with no charges. If you prefer to close it immediately, log in to the Twilio Console.
- Click Settings in the left sidebar.
- Select Account settings.
- Select Account details & security.
- Click Close account, review all acknowledgements, and confirm.
Delete your local project files:
macOS
- Remove the project folder from your Desktop by running:
rm -rf ~/Desktop/[[PROJECT_FOLDER="your-project-folder-name"]]
Windows
- Remove the project folder from your Desktop by running:
Remove-Item -Recurse -Force "$HOME\Desktop\[[PROJECT_FOLDER="your-project-folder-name"]]"
Nice Work!
Nice Work!
Nice work! You just built a complete patient appointment reminder system that extracts data from scanned documents and automatically contacts patients via text and phone call.
You've learned how to:
- Build a document processing pipeline using AWS Textract to extract text from scanned medical documents and parse unstructured OCR output into clean, structured appointment records with regex-based text processing.
- Establish HIPAA compliance foundations by signing a Business Associate Addendum with AWS via Artifact and configuring server-side encryption on your S3 storage.
- Send automated SMS reminders following HIPAA-safe messaging patterns (no PHI in text messages) and make voice calls with personalized appointment details using Twilio's TwiML text-to-speech.
- Secret Mission: Added appointment confirmation tracking by checking for patient SMS replies and reporting confirmed, declined, or no-response statuses.
Ready to quiz yourself?