Build a Court Decision Notifier
Monitor US and Ohio Supreme Court decisions and push alerts to your phone.
Introduction
30 Second Summary
Major court decisions shape everything from your rights at work to what apps can do with your data. But checking court websites every day to find out when something new drops is tedious and easy to forget.
In this project, you will build a Python script that monitors the US Supreme Court and the Ohio Supreme Court for new decisions and sends push notifications straight to your phone.
What You'll Build
A Python command-line tool called "Court Watch" that pulls data from a REST API and an RSS feed, tracks what you have already seen, and pushes alerts to your Android phone.
By the end of this project, you'll have:
- A court_watch.py script that fetches court data from two different source types (REST API and RSS feed).
- A seen_cases.json file that persists which cases you have already been notified about, preventing duplicate alerts.
- Push notifications on your Android phone via ntfy with case names and court source.
- Secret Mission: Add a daily email digest that summarizes all new decisions from the past 24 hours.
Are there any prerequisites?
None. This guide walks you through setting up everything you need, including Python, your code editor, and all required accounts. You just need an Android phone for receiving push notifications.
Before We Start
Before you can monitor courts and receive alerts, you need a few things in place. Your script will pull data from two different sources and push notifications to your phone, so each piece of the puzzle needs to be ready.
In this step, you will set up three things: a notification channel on your phone, an API account for court data, and a Python environment with the right packages installed.
In this step, get ready to:
- Install the ntfy app and subscribe to your notification topic.
- Create a CourtListener account and retrieve your API token.
- Set up a Python virtual environment with the required packages.
Install the ntfy app on your Android phone
Your script needs a way to send you real-time push notifications. ntfy is a free notification service that lets any script send alerts to your phone with a single HTTP request. No account required.
- On your Android phone, open the Google Play Store.
- Search for ntfy and install the app.
- Open the ntfy app.
- Tap the + button to subscribe to a new topic.
- Type a unique topic name that only you would guess (for example, yourname-court-watch-2026). This is your private notification channel.
- Tap Subscribe.
Why does the topic name matter?
Topic names on ntfy are public. Anyone who knows your topic name can send messages to it. Pick something long and hard to guess, like a combination of your name and a random word. The topic name can contain letters, numbers, dashes, and underscores, up to 64 characters.
Write down or remember your topic name. You will use it in your Python script later.
Don't see the ntfy app?
Make sure you are searching in Google Play, not a web browser. The app is also available on F-Droid if Google Play is not accessible on your device.
help me find and install the ntfy app on my Android phone
Create a CourtListener account and get your API token
CourtListener is a free legal research platform maintained by the Free Law Project. It provides a REST API that lets you programmatically search millions of court opinions. You need a free account to authenticate your API requests.
- On your computer, open a browser and go to:
- https://www.courtlistener.com/sign-in/
- Click the Need to register? link.
- Fill in your name, email, and a password, then submit the registration form.
- Check your email for a confirmation link and click it to verify your account.
- Once signed in, navigate to your API token page:
- https://www.courtlistener.com/profile/api-token
- Copy your API token and save it somewhere safe (a text file or password manager). You will need it in Step 2.
What is an API token?
An API token is a long string of characters that proves your identity when making requests to a service. It works like a password, but is designed for scripts and programs rather than manual sign-ins. Keep it private.
Can't find your API token?
Make sure you are signed in to CourtListener. Your token is displayed on your profile page under the API section. If you do not see it, try logging out and signing back in.
help me find my CourtListener API token
Set up your Python environment
Python virtual environments keep each project's packages separate from your system Python. This prevents version conflicts between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
Now open a terminal inside VS Code.
- Press Ctrl+` (backtick key, below Escape) to open the integrated terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir court-watch
cd court-watch
- Create a Python virtual environment by running:
python -m venv venv
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install the two packages your project needs by running:
pip install requests==2.34.2 feedparser==6.0.12
What are these packages?
requests is the standard Python library for making HTTP requests. It handles headers, parameters, and JSON parsing with a clean, readable API.
feedparser is the standard Python library for parsing RSS and Atom feeds. It normalizes different feed formats so you can read entries the same way regardless of the source.
You should see output ending with a success message confirming both packages were installed.
Seeing a permission error or command not found?
Make sure (venv) appears in your terminal prompt. If it does not, you need to activate the virtual environment first. Re-run the activation command for your operating system.
help me fix my Python virtual environment or pip install error
- Finally, open the project folder in VS Code by pressing Ctrl+K Ctrl+O (or Cmd+K Cmd+O on macOS), then selecting the court-watch folder on your Desktop.
Your environment is ready. You have ntfy listening on your phone, a CourtListener API token for court data, and a Python virtual environment with the right packages installed.
Next up, you will write your first function to pull live US Supreme Court opinions from the CourtListener API.
Fetch US Supreme Court Opinions
Your environment is ready with Python, your packages, and a CourtListener account. Now it's time to write your first real function that talks to an external API.
The CourtListener REST API gives you access to millions of US court opinions. In this step, you'll write a Python function that fetches the 10 most recent US Supreme Court opinions and prints them to your terminal.
In this step, get ready to:
- Create a court_watch.py file with configuration constants for the APIs and services you'll use.
- Write a function that calls the CourtListener opinions endpoint, filtering for SCOTUS cases.
- Run the script and verify that real court data prints to your terminal.
Create court_watch.py with configuration constants
Before writing any logic, you need a file to hold your script and a set of configuration constants that tell it where to find each data source.
- In VS Code, create a new file called court_watch.py inside your project folder.
- Paste the following imports and configuration at the top of the file:
import json
import os
import sys
from pathlib import Path
import feedparser
import requests
# --- Configuration ---
# Pull your CourtListener token from an environment variable
COURTLISTENER_TOKEN = os.environ.get("COURTLISTENER_TOKEN", "your-token-here")
# The ntfy topic you subscribed to on your phone
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-court-watch-topic")
# File path for tracking which cases you've already seen
SEEN_CASES_FILE = Path("seen_cases.json")
# API endpoints for each data source
COURTLISTENER_API_URL = "https://www.courtlistener.com/api/rest/v4/opinions/"
OHIO_RSS_URL = "https://www.supremecourt.ohio.gov/rss/cases/dailyannouncement.xml"
NTFY_URL = "https://ntfy.sh"
What does this code do?
The first block imports the libraries you installed in Step 1. requests handles HTTP calls and feedparser will parse RSS feeds later.
The configuration section uses os.environ.get() to read sensitive values (like your API token) from environment variables instead of hardcoding them. The second argument is a fallback value used when the variable isn't set.
- Save court_watch.py.
- You won't see a change yet. The next chunk adds the function that actually calls the API.
Seeing a red underline on the imports?
Make sure your Python virtual environment is selected as the interpreter in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your venv folder.
Help me fix import errors in VS Code
Write the function to fetch SCOTUS opinions
The CourtListener API requires an authentication header and accepts query parameters to filter results. Your function will request SCOTUS opinions sorted by newest first.
- Below the configuration constants in court_watch.py, add the following function:
# --- Data Fetching ---
def fetch_scotus_opinions():
"""Fetch recent US Supreme Court opinions from CourtListener API."""
headers = {"Authorization": f"Token {COURTLISTENER_TOKEN}"}
params = {
"cluster__docket__court": "scotus",
"order_by": "-date_created",
"page_size": 10,
}
response = requests.get(COURTLISTENER_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
cases = []
for result in data.get("results", []):
case = {
"id": f"scotus-{result['id']}",
"source": "US Supreme Court",
"title": result.get("download_url", "Unknown Case"),
"date": result.get("date_created", "Unknown Date"),
"url": f"https://www.courtlistener.com{result.get('absolute_url', '')}",
}
cases.append(case)
return cases
What does this function do?
The headers dictionary sends your API token using the format CourtListener expects: Authorization: Token <your-token>.
The params dictionary filters results to SCOTUS opinions (cluster__docket__court), orders them newest-first (-date_created), and limits to 10 results.
The for loop builds a list of simplified case dictionaries. Each case gets a unique id, the court source, a title, the date, and a full url back to CourtListener.
- Save court_watch.py.
- You won't see a change yet. The next chunk adds a test runner so you can verify this function works.
Run the script to verify data is flowing
To test your function, you need a small block of code at the bottom of the file that runs only when you execute the script directly.
- At the very bottom of court_watch.py, add the following test block:
if __name__ == "__main__":
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions")
for c in scotus_cases:
print(c)
What is __name__ == "__main__"?
This is a standard Python pattern. When you run a file directly with python court_watch.py, Python sets the special variable __name__ to "__main__".
Code inside this block only runs when the file is executed directly. If the file is imported by another script later, this block is skipped.
Before running, you need to set your CourtListener API token as an environment variable so the script can authenticate.
- In your terminal (with your virtual environment still active), set the environment variable by running:
macOS
export COURTLISTENER_TOKEN=your-api-token-here
Windows
$env:COURTLISTENER_TOKEN="your-api-token-here"
- Replace your-api-token-here with the actual API token you copied from your CourtListener account in Step 1.
- Run the script by entering:
python court_watch.py
- You should see output that starts with Fetching US Supreme Court opinions... followed by a count and a list of dictionaries with id, source, title, date, and url fields printed for each case.
Seeing a 401 Unauthorized error?
This means your token wasn't recognized. Double check that you copied the full token from CourtListener with no extra spaces. Make sure you set the environment variable in the same terminal session where you run the script.
Help me fix my CourtListener authentication error
Seeing a ConnectionError or timeout?
Check that you have an active internet connection. The CourtListener API has rate limits of 5 requests per minute for free accounts, so if you ran the script many times quickly, wait a minute and try again.
Help me debug my connection to the CourtListener API
✔️ Awesome, I've got everything!
Great. Double check you've saved court_watch.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete court_watch.py file at this point:
import json
import os
import sys
from pathlib import Path
import feedparser
import requests
# --- Configuration ---
COURTLISTENER_TOKEN = os.environ.get("COURTLISTENER_TOKEN", "your-token-here")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-court-watch-topic")
SEEN_CASES_FILE = Path("seen_cases.json")
COURTLISTENER_API_URL = "https://www.courtlistener.com/api/rest/v4/opinions/"
OHIO_RSS_URL = "https://www.supremecourt.ohio.gov/rss/cases/dailyannouncement.xml"
NTFY_URL = "https://ntfy.sh"
# --- Data Fetching ---
def fetch_scotus_opinions():
"""Fetch recent US Supreme Court opinions from CourtListener API."""
headers = {"Authorization": f"Token {COURTLISTENER_TOKEN}"}
params = {
"cluster__docket__court": "scotus",
"order_by": "-date_created",
"page_size": 10,
}
response = requests.get(COURTLISTENER_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
cases = []
for result in data.get("results", []):
case = {
"id": f"scotus-{result['id']}",
"source": "US Supreme Court",
"title": result.get("download_url", "Unknown Case"),
"date": result.get("date_created", "Unknown Date"),
"url": f"https://www.courtlistener.com{result.get('absolute_url', '')}",
}
cases.append(case)
return cases
if __name__ == "__main__":
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions")
for c in scotus_cases:
print(c)
You've confirmed that real court data is flowing into your script. Next up, you'll add a second data source by parsing the Ohio Supreme Court's RSS feed.
Parse Ohio Supreme Court RSS Feed
Your CourtListener API function is pulling SCOTUS opinions into a clean list of case dictionaries.
Now you need a second data source. The Ohio Supreme Court publishes daily case announcements through an RSS feed. Instead of authenticating with an API and parsing JSON, you will fetch raw XML and let the feedparser library do the heavy lifting.
In this step, get ready to:
- Write a function that parses the Ohio Supreme Court Daily Case Announcement RSS feed.
- Extract the title, publication date, and link from each feed entry.
- Print parsed entries to verify the RSS data is flowing correctly.
Write the fetch_ohio_announcements() function
The feedparser library handles all RSS and Atom feed variants automatically. You pass it a URL, and it returns a structured object with normalized fields like title, published, link, and id for each entry.
- In court_watch.py, find the closing return cases line of your fetch_scotus_opinions() function.
- Add the following function below it, leaving one blank line between functions:
def fetch_ohio_announcements():
"""Fetch Ohio Supreme Court daily case announcements from RSS feed."""
feed = feedparser.parse(OHIO_RSS_URL)
cases = []
for entry in feed.entries:
case = {
"id": f"ohio-{entry.get('id', entry.get('link', entry.get('title', '')))}",
"source": "Ohio Supreme Court",
"title": entry.get("title", "Unknown Case"),
"date": entry.get("published", "Unknown Date"),
"url": entry.get("link", ""),
}
cases.append(case)
return cases
What does this code do?
This function mirrors the structure of fetch_scotus_opinions() so both data sources produce the same dictionary shape.
- feedparser.parse(OHIO_RSS_URL) fetches the XML feed and parses it into a Python object with a .entries list.
- Each entry exposes standard fields like title, published, link, and id. The .get() calls provide safe fallbacks if a field is missing.
- The id field uses a triple-fallback chain (id then link then title) because not every RSS entry includes a unique guid element.
- The "ohio-" prefix keeps Ohio case IDs distinct from SCOTUS IDs when you combine them later.
Why feedparser instead of manual XML parsing?
RSS has multiple versions (0.91, 1.0, 2.0) and Atom is a separate format entirely. Each uses different element names and date formats. The feedparser library normalizes all of these into a single consistent interface so you never have to worry about which spec the feed follows.
- Save court_watch.py.
Seeing a red underline under feedparser?
Make sure your virtual environment is still active. If your terminal prompt does not show (venv) at the start, reactivate it and confirm feedparser is installed.
Help me fix a red underline on my feedparser import
Update the main block to call the Ohio function
Your script's if __name__ == "__main__" block currently only calls fetch_scotus_opinions(). You need to add a call to the new Ohio function so you can verify it works.
- In court_watch.py, find the last line of the if __name__ == "__main__" block:
for c in scotus_cases:
print(c)
- Add the following lines directly below it, at the same indentation level:
print("\nFetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements")
for c in ohio_cases:
print(c)
What does this addition do?
This calls your new fetch_ohio_announcements() function and prints each case dictionary so you can inspect the data. The \n at the start of the print statement adds a blank line between the SCOTUS output and the Ohio output for readability.
- Save court_watch.py.
- Run the script in your terminal by running this command:
python court_watch.py
You should see your SCOTUS opinions printed first, followed by a list of Ohio Supreme Court case dictionaries with id, source, title, date, and url fields.
Not seeing any Ohio cases?
The Ohio Supreme Court only publishes announcements on business days. If you run the script on a weekend or holiday, the feed may be empty. Check that the script prints Found 0 recent announcements without errors. That still means your function works correctly.
If you see a connection error instead, confirm your internet connection is active and that you can reach supremecourt.ohio.gov in your browser.
Help me debug my Ohio RSS fetch
✔️ Awesome, I've got everything!
Great. Double check you have saved court_watch.py with both functions and the updated main block.
ⓧ I'd like to double check the full code
Here is the complete court_watch.py file at this point:
import json
import os
import sys
from pathlib import Path
import feedparser
import requests
# --- Configuration ---
COURTLISTENER_TOKEN = os.environ.get("COURTLISTENER_TOKEN", "your-token-here")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-court-watch-topic")
SEEN_CASES_FILE = Path("seen_cases.json")
COURTLISTENER_API_URL = "https://www.courtlistener.com/api/rest/v4/opinions/"
OHIO_RSS_URL = "https://www.supremecourt.ohio.gov/rss/cases/dailyannouncement.xml"
NTFY_URL = "https://ntfy.sh"
# --- Data Fetching ---
def fetch_scotus_opinions():
"""Fetch recent US Supreme Court opinions from CourtListener API."""
headers = {"Authorization": f"Token {COURTLISTENER_TOKEN}"}
params = {
"cluster__docket__court": "scotus",
"order_by": "-date_created",
"page_size": 10,
}
response = requests.get(COURTLISTENER_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
cases = []
for result in data.get("results", []):
case = {
"id": f"scotus-{result['id']}",
"source": "US Supreme Court",
"title": result.get("download_url", "Unknown Case"),
"date": result.get("date_created", "Unknown Date"),
"url": f"https://www.courtlistener.com{result.get('absolute_url', '')}",
}
cases.append(case)
return cases
def fetch_ohio_announcements():
"""Fetch Ohio Supreme Court daily case announcements from RSS feed."""
feed = feedparser.parse(OHIO_RSS_URL)
cases = []
for entry in feed.entries:
case = {
"id": f"ohio-{entry.get('id', entry.get('link', entry.get('title', '')))}",
"source": "Ohio Supreme Court",
"title": entry.get("title", "Unknown Case"),
"date": entry.get("published", "Unknown Date"),
"url": entry.get("link", ""),
}
cases.append(case)
return cases
if __name__ == "__main__":
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions")
for c in scotus_cases:
print(c)
print("\nFetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements")
for c in ohio_cases:
print(c)
Both of your data sources are now pulling court decisions into a consistent format. Next up, you will send push notifications to your phone whenever a new decision drops.
Send Push Notifications via ntfy
Your script can now pull data from two different courts. The next challenge is getting that data to your phone in real time.
In this step, you will write a notification function that sends an HTTP POST request to ntfy, then wire it up so every court case triggers an alert on your Android phone.
In this step, get ready to:
- Write a function that sends a push notification via a simple HTTP POST.
- Integrate notifications with both court data sources.
- Test the script and confirm alerts arrive on your phone.
Write the push notification function
Sending a notification with ntfy is a single HTTP POST request. The message body becomes the notification text, and a Title header sets the notification title.
- In court_watch.py, find the last line of the fetch_ohio_announcements() function (return cases).
- Add the following notification section below it, leaving one blank line after return cases:
# --- Notifications ---
def send_push_notification(title, message):
"""Send a push notification via ntfy."""
requests.post(
f"{NTFY_URL}/{NTFY_TOPIC}",
data=message.encode("utf-8"),
headers={"Title": title},
)
What does this code do?
This function sends a single push notification to your phone by making an HTTP POST to the ntfy server.
- f"{NTFY_URL}/{NTFY_TOPIC}" builds the full URL using the topic you subscribed to on your phone.
- data=message.encode("utf-8") sends the notification body text as UTF-8 bytes.
- headers={"Title": title} sets the bold title line that appears at the top of the notification.
- Save court_watch.py.
You will not see a change yet. The next chunk builds the loop that calls this function for each case.
Integrate notifications with both court sources
A single notification function is useful, but you need a loop that formats each court case and fires off an alert. You also need a main() function that orchestrates the full flow: fetch, combine, notify.
- Directly below send_push_notification, add the notify_new_cases function by pasting the following code:
def notify_new_cases(new_cases):
"""Send a push notification for each new case."""
for case in new_cases:
title = f"New {case['source']} Decision"
message = f"{case['title']}\n{case['date']}\n{case['url']}"
send_push_notification(title, message)
print(f" Notified: {case['title']}")
What does this code do?
This function iterates through a list of case dictionaries and sends a push notification for each one.
- title is formatted as "New US Supreme Court Decision" or "New Ohio Supreme Court Decision" depending on the case source.
- message combines the case name, date, and URL on separate lines so the notification is easy to read.
- The print line logs which cases were notified so you can see progress in the terminal.
- Save court_watch.py.
You will not see a change yet. The next chunk replaces the existing test block with a proper main() function.
- Scroll to the bottom of court_watch.py and find the existing if __name__ == "__main__": block.
- Delete the entire if __name__ block (everything from if __name__ == "__main__": to the last print(c) line).
- Replace it with the following main() function and entry point:
# --- Main ---
def main():
print("Court Watch - Checking for new decisions...\n")
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions\n")
print("Fetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements\n")
all_cases = scotus_cases + ohio_cases
print(f"Sending notifications for {len(all_cases)} case(s)...")
notify_new_cases(all_cases)
print("\nDone!")
if __name__ == "__main__":
main()
What does this code do?
The main() function orchestrates the entire script flow in a clean, readable sequence.
- It fetches cases from both courts using the functions you already wrote.
- It combines both lists into all_cases and passes them to notify_new_cases which triggers a push notification for each.
- The if __name__ == "__main__": guard ensures main() only runs when you execute the file directly.
- Save court_watch.py.
Seeing a syntax error?
Make sure you deleted the entire old if __name__ block before pasting the new code. Leftover lines from the previous version cause indentation or duplicate-definition errors.
help me fix a syntax error in court_watch.py after replacing the main block
✔️ Awesome, I've got everything!
Great. Double check you have saved court_watch.py before moving on.
ⓧ I'd like to double check the full code
import json
import os
import sys
from pathlib import Path
import feedparser
import requests
# --- Configuration ---
COURTLISTENER_TOKEN = os.environ.get("COURTLISTENER_TOKEN", "your-token-here")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-court-watch-topic")
SEEN_CASES_FILE = Path("seen_cases.json")
COURTLISTENER_API_URL = "https://www.courtlistener.com/api/rest/v4/opinions/"
OHIO_RSS_URL = "https://www.supremecourt.ohio.gov/rss/cases/dailyannouncement.xml"
NTFY_URL = "https://ntfy.sh"
# --- Data Fetching ---
def fetch_scotus_opinions():
"""Fetch recent US Supreme Court opinions from CourtListener API."""
headers = {"Authorization": f"Token {COURTLISTENER_TOKEN}"}
params = {
"cluster__docket__court": "scotus",
"order_by": "-date_created",
"page_size": 10,
}
response = requests.get(COURTLISTENER_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
cases = []
for result in data.get("results", []):
case = {
"id": f"scotus-{result['id']}",
"source": "US Supreme Court",
"title": result.get("download_url", "Unknown Case"),
"date": result.get("date_created", "Unknown Date"),
"url": f"https://www.courtlistener.com{result.get('absolute_url', '')}",
}
cases.append(case)
return cases
def fetch_ohio_announcements():
"""Fetch Ohio Supreme Court daily case announcements from RSS feed."""
feed = feedparser.parse(OHIO_RSS_URL)
cases = []
for entry in feed.entries:
case = {
"id": f"ohio-{entry.get('id', entry.get('link', entry.get('title', '')))}",
"source": "Ohio Supreme Court",
"title": entry.get("title", "Unknown Case"),
"date": entry.get("published", "Unknown Date"),
"url": entry.get("link", ""),
}
cases.append(case)
return cases
# --- Notifications ---
def send_push_notification(title, message):
"""Send a push notification via ntfy."""
requests.post(
f"{NTFY_URL}/{NTFY_TOPIC}",
data=message.encode("utf-8"),
headers={"Title": title},
)
def notify_new_cases(new_cases):
"""Send a push notification for each new case."""
for case in new_cases:
title = f"New {case['source']} Decision"
message = f"{case['title']}\n{case['date']}\n{case['url']}"
send_push_notification(title, message)
print(f" Notified: {case['title']}")
# --- Main ---
def main():
print("Court Watch - Checking for new decisions...\n")
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions\n")
print("Fetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements\n")
all_cases = scotus_cases + ohio_cases
print(f"Sending notifications for {len(all_cases)} case(s)...")
notify_new_cases(all_cases)
print("\nDone!")
if __name__ == "__main__":
main()
Test notifications on your phone
Before running the script, you need to make sure the NTFY_TOPIC environment variable matches the topic you subscribed to on your phone.
- Set your ntfy topic and CourtListener token as environment variables by running these commands (replace the placeholder values with your actual topic name and token):
set COURTLISTENER_TOKEN=your-token-here
set NTFY_TOPIC=[[NTFY_TOPIC="your-court-watch-topic"]]
What is NTFY_TOPIC?
This is the channel name you chose when you subscribed in the ntfy app on your phone. It acts as a password. Anyone who knows the topic name can send notifications to it, so pick something unique and non-guessable.
- Run the script by executing this command:
python court_watch.py
- Check your Android phone. You should see push notifications arriving from ntfy with case titles and court names.
What should I see?
In your terminal, you should see output like:
Court Watch - Checking for new decisions...
Fetching US Supreme Court opinions...
Found 10 recent opinions
Fetching Ohio Supreme Court announcements...
Found X recent announcements
Sending notifications for Y case(s)...
Notified: ...
Notified: ...
Done!
On your phone, each notification shows a bold title like "New US Supreme Court Decision" with the case details below it.
Not seeing notifications on your phone?
- Make sure the topic name in your NTFY_TOPIC environment variable exactly matches what you subscribed to in the ntfy app (case-sensitive).
- Confirm your phone has an active internet connection and the ntfy app is not battery-optimized away.
- If you see a 401 error in the terminal, your COURTLISTENER_TOKEN is missing or incorrect. Double check you copied the full token from your CourtListener account.
help me debug why my ntfy notifications are not arriving on my phone
Your Court Watch tool now fetches cases and buzzes your phone. Next up, you will add duplicate tracking so the script only notifies you about cases you have not seen before.
Track Seen Cases and Prevent Duplicates
Your Court Watch script fetches cases and sends notifications. But right now, every single run sends notifications for all cases it finds. Run it twice, and you get the same alerts twice.
A real monitoring tool only alerts you about things you have not seen yet. In this step, you will add a persistence layer that remembers which cases have already triggered notifications. The script will save case IDs to a seen_cases.json file and skip anything it has already reported.
In this step, get ready to:
- Write functions to load and save a JSON file that tracks seen case IDs.
- Add duplicate-checking logic so the script only notifies about new cases.
- Run the script twice and confirm the second run produces zero notifications.
Write the persistence functions
To remember which cases have been seen, the script needs three capabilities: reading a file of previously seen IDs, writing updated IDs back to that file, and filtering a list of cases down to only the unseen ones.
- In court_watch.py, find the notify_new_cases function near the bottom of the Notifications section.
- Below it (and above the existing main function), add the following persistence functions:
# --- Persistence ---
def load_seen_cases():
"""Load previously seen case IDs from file."""
if SEEN_CASES_FILE.exists():
with open(SEEN_CASES_FILE, "r") as f:
return set(json.load(f))
return set()
def save_seen_cases(seen):
"""Save seen case IDs to file."""
with open(SEEN_CASES_FILE, "w") as f:
json.dump(list(seen), f, indent=2)
def filter_new_cases(cases, seen):
"""Return only cases not previously seen."""
return [case for case in cases if case["id"] not in seen]
What does this code do?
- load_seen_cases() checks if seen_cases.json exists. If it does, it reads the JSON array and converts it to a Python set for fast lookups. If the file does not exist yet (first run), it returns an empty set.
- save_seen_cases(seen) converts the set back to a list (since JSON cannot store sets) and writes it to seen_cases.json with readable indentation.
- filter_new_cases(cases, seen) uses a list comprehension to return only cases whose id is not already in the seen set.
Why use a set instead of a list?
Checking whether an item exists in a set is nearly instant, regardless of how many items are stored. Checking a list gets slower as the list grows. Since you will check every fetched case against the seen collection, a set keeps things fast.
- Save court_watch.py.
You will not see a visible change yet. The next substep wires these functions into your main logic.
Seeing a red underline on SEEN_CASES_FILE?
Make sure the new functions are placed below the notifications section, not above the configuration block where SEEN_CASES_FILE is defined. Python reads top to bottom, so constants must appear before the code that uses them.
Help me fix a NameError for SEEN_CASES_FILE in my court_watch.py persistence functions.
Update main() to filter and track cases
The persistence functions exist, but main() still sends notifications for everything. You need to rewire the main logic to load seen cases, filter the results, and save after notifying.
- In court_watch.py, find the existing main() function (the # --- Main --- section).
- Replace the entire function with this updated version:
# --- Main ---
def main():
print("Court Watch - Checking for new decisions...\n")
# Load previously seen cases
seen = load_seen_cases()
# Fetch from both courts
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions\n")
print("Fetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements\n")
# Filter to only new cases
all_cases = scotus_cases + ohio_cases
new_cases = filter_new_cases(all_cases, seen)
if new_cases:
print(f"Sending notifications for {len(new_cases)} new case(s)...")
notify_new_cases(new_cases)
# Update seen cases
for case in new_cases:
seen.add(case["id"])
save_seen_cases(seen)
else:
print("No new cases since last check.")
print("\nDone!")
if __name__ == "__main__":
main()
What changed in main()?
- seen = load_seen_cases() loads the set of previously notified case IDs at the start of each run.
- filter_new_cases(all_cases, seen) removes anything the script has already sent a notification for.
- The if new_cases: conditional means notifications only fire when there is something genuinely new.
- After notifying, the script adds each new case ID to the set and saves the updated file. This ensures the next run skips these cases.
- Save court_watch.py.
Seeing an indentation error?
Make sure the entire old main() function body is removed before pasting the new version. Leftover lines from the old function sitting at the wrong indent level will cause a syntax error.
Help me fix an indentation or syntax error in my updated main() function.
Run the script twice to verify duplicate prevention
The real test of duplicate prevention is running the script back to back. The first run should notify you about all cases it finds. The second run should find zero new cases because everything was recorded in seen_cases.json.
- Run the script for the first time by running this command:
python court_watch.py
You should see output listing the fetched cases, followed by notification confirmations. Your phone should receive push notifications.
- Confirm that a seen_cases.json file now exists by running:
ls
You should see seen_cases.json listed alongside court_watch.py.
- Now run the script a second time:
python court_watch.py
This time, you should see the message No new cases since last check. and receive zero phone notifications. Every case from the first run was recorded, so the filter removes them all.
What is inside seen_cases.json?
The file contains a JSON array of case ID strings like scotus-123456 and ohio-https://.... Each ID uniquely identifies a case so the script can recognize it on future runs.
Still getting notifications on the second run?
Check that seen_cases.json was created after the first run. If it is missing, the save_seen_cases call might be outside the if new_cases: block or the function has an indentation error.
Also verify that your filter_new_cases function checks case["id"] against the set. A typo in the key name would cause it to never match.
Help me debug why my second run still sends duplicate notifications.
✔️ Awesome, I've got everything!
Great. Double check you have saved court_watch.py and that seen_cases.json exists on disk.
ⓧ I'd like to double check the full code
Here is the complete court_watch.py at this point:
import json
import os
import sys
from pathlib import Path
import feedparser
import requests
# --- Configuration ---
COURTLISTENER_TOKEN = os.environ.get("COURTLISTENER_TOKEN", "your-token-here")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "your-court-watch-topic")
SEEN_CASES_FILE = Path("seen_cases.json")
COURTLISTENER_API_URL = "https://www.courtlistener.com/api/rest/v4/opinions/"
OHIO_RSS_URL = "https://www.supremecourt.ohio.gov/rss/cases/dailyannouncement.xml"
NTFY_URL = "https://ntfy.sh"
# --- Data Fetching ---
def fetch_scotus_opinions():
"""Fetch recent US Supreme Court opinions from CourtListener API."""
headers = {"Authorization": f"Token {COURTLISTENER_TOKEN}"}
params = {
"cluster__docket__court": "scotus",
"order_by": "-date_created",
"page_size": 10,
}
response = requests.get(COURTLISTENER_API_URL, headers=headers, params=params)
response.raise_for_status()
data = response.json()
cases = []
for result in data.get("results", []):
case = {
"id": f"scotus-{result['id']}",
"source": "US Supreme Court",
"title": result.get("download_url", "Unknown Case"),
"date": result.get("date_created", "Unknown Date"),
"url": f"https://www.courtlistener.com{result.get('absolute_url', '')}",
}
cases.append(case)
return cases
def fetch_ohio_announcements():
"""Fetch Ohio Supreme Court daily case announcements from RSS feed."""
feed = feedparser.parse(OHIO_RSS_URL)
cases = []
for entry in feed.entries:
case = {
"id": f"ohio-{entry.get('id', entry.get('link', entry.get('title', '')))}",
"source": "Ohio Supreme Court",
"title": entry.get("title", "Unknown Case"),
"date": entry.get("published", "Unknown Date"),
"url": entry.get("link", ""),
}
cases.append(case)
return cases
# --- Notifications ---
def send_push_notification(title, message):
"""Send a push notification via ntfy."""
requests.post(
f"{NTFY_URL}/{NTFY_TOPIC}",
data=message.encode("utf-8"),
headers={"Title": title},
)
def notify_new_cases(new_cases):
"""Send a push notification for each new case."""
for case in new_cases:
title = f"New {case['source']} Decision"
message = f"{case['title']}\n{case['date']}\n{case['url']}"
send_push_notification(title, message)
print(f" Notified: {case['title']}")
# --- Persistence ---
def load_seen_cases():
"""Load previously seen case IDs from file."""
if SEEN_CASES_FILE.exists():
with open(SEEN_CASES_FILE, "r") as f:
return set(json.load(f))
return set()
def save_seen_cases(seen):
"""Save seen case IDs to file."""
with open(SEEN_CASES_FILE, "w") as f:
json.dump(list(seen), f, indent=2)
def filter_new_cases(cases, seen):
"""Return only cases not previously seen."""
return [case for case in cases if case["id"] not in seen]
# --- Main ---
def main():
print("Court Watch - Checking for new decisions...\n")
# Load previously seen cases
seen = load_seen_cases()
# Fetch from both courts
print("Fetching US Supreme Court opinions...")
scotus_cases = fetch_scotus_opinions()
print(f" Found {len(scotus_cases)} recent opinions\n")
print("Fetching Ohio Supreme Court announcements...")
ohio_cases = fetch_ohio_announcements()
print(f" Found {len(ohio_cases)} recent announcements\n")
# Filter to only new cases
all_cases = scotus_cases + ohio_cases
new_cases = filter_new_cases(all_cases, seen)
if new_cases:
print(f"Sending notifications for {len(new_cases)} new case(s)...")
notify_new_cases(new_cases)
# Update seen cases
for case in new_cases:
seen.add(case["id"])
save_seen_cases(seen)
else:
print("No new cases since last check.")
print("\nDone!")
if __name__ == "__main__":
main()
Secret mission
Add a Daily Email Digest
Your script already sends push notifications for each new case. But what if you want a single summary email with all the decisions from a run? In this secret mission, you will add a daily email digest feature that compiles all new cases into a formatted email and sends it to your Gmail inbox on demand.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with free services, so there are no ongoing costs. Decide whether to keep your resources, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop containing court_watch.py, seen_cases.json, and the venv/ virtual environment.
- CourtListener free account and API token.
- ntfy app installed on your Android phone.
- Gmail App Password (if you completed the Secret Mission).
Keep everything running
No action needed. Choose this if you plan to keep monitoring courts or want to extend the script further.
- Your CourtListener account is free and has no ongoing cost. Your API token remains active on your profile.
- The ntfy app uses minimal battery and storage on your phone.
- Your project files stay on your Desktop ready for the next run.
- If you completed the Secret Mission, your Gmail App Password remains active until you revoke it.
Pause - I'll come back to this later
Close running processes to free up memory, but keep your files and accounts intact for later.
- Close VS Code and any open terminal windows.
- If your virtual environment is still active, deactivate it by running:
- Your project files, seen_cases.json, and CourtListener account all remain ready for when you return.
Delete - I don't want to use this again
Remove all project resources and accounts to start fresh.
- Delete the project folder from your Desktop by running this command in your terminal:
- Delete your CourtListener account by visiting courtlistener.com/help/delete-account and clicking Delete My Account. This removes your API token and all associated data.
- Uninstall the ntfy app from your Android phone by long-pressing the app icon and selecting Uninstall.
- If you completed the Secret Mission, revoke your Gmail App Password by visiting myaccount.google.com/apppasswords and deleting the password you created.
Nice Work!
Nice Work!
Nice work! You just built a Python command-line tool that monitors two courts for new decisions and pushes real-time alerts straight to your phone.
You've learned how to:
- Consume a REST API with authenticated requests to fetch US Supreme Court opinions from the CourtListener API.
- Parse an RSS feed using feedparser to extract structured data from the Ohio Supreme Court Daily Case Announcements.
- Send push notifications to your Android phone via ntfy using a simple HTTP POST request, and track seen cases with a JSON persistence file to prevent duplicate alerts.
- Secret Mission: Added a daily email digest via Gmail SMTP with App Password authentication using Python's built-in smtplib.
Ready to quiz yourself?