Skip to content
Abdullah Al Rafi

Lazy Termin: appointment alerts that never double-send

A Selenium checker that watches a German appointment-booking page and alerts verified subscribers on Telegram through a PostgreSQL outbox.

Summary

Role
Sole developer
Timeline
Aug 2026 – Present
Stack
Python · Selenium · PostgreSQL · python-telegram-bot · Amazon SES · Docker Compose · GitHub Actions

41

unit tests, against a real PostgreSQL

≤1

alert per subscriber per cooldown

60-minute cooldown by default

3

delivery attempts before the admin is told

On this page
  1. Summary
  2. Problem
  3. Constraints
  4. Approach and architecture
  5. Results
  6. What I'd do differently

Problem

In Germany, many public offices hand out appointments (Termine) through online booking pages. Free slots appear unpredictably and go fast, so getting one means stepping through the same multi-page booking flow again and again. Lazy Termin does the checking and tells people the moment a slot opens. They then book it themselves on the official site.

Constraints

  • Be a good citizen. The checker uses the normal booking flow, waits a random one to three seconds between steps, and never bypasses CAPTCHAs, rate limits or logins. It never books anything. Scheduled runs happen every 15 minutes during weekday office hours (Europe/Berlin).
  • Never lose an alert, never send one twice. The checker and the Telegram bot are separate processes, and either can restart at any time.
  • Don't spam. Each subscriber gets at most one alert per cooldown window, 60 minutes by default.
  • Keep the audience small and real. Subscribing needs an email address on an allowed domain, verified with a one-time code sent through Amazon SES. Subscriptions are capped and expire after five days.
  • Collect as little as possible. Message telemetry stores a timestamp, the Telegram user ID and the message type, never the message itself.

Approach and architecture

    • cron in Docker Compose

      schedule

      Every 15 minutes, weekdays 07:00–16:45, Europe/Berlin

    • GitHub Actions

      schedule

      Weekday windows, or started by hand

  1. Selenium booking flow

    checker

    Headless Chrome · random 1–3 s wait between steps

  2. slot found → INSERT + NOTIFY

    notification_outbox

    postgresql

    At most one pending APPOINTMENT_FOUND row

  3. LISTEN, or poll every 20 s

    Claim with SKIP LOCKED

    bot

    Select subscribers outside their cooldown

  4. Alert each eligible subscriber

    telegram

    Every delivery recorded in alert_delivery · 3 failures → admin

Fig. 1 · Alert path. The checker and the bot only meet in PostgreSQL.
Diagram description

A scheduler starts the Selenium checker, either through cron in Docker Compose or through GitHub Actions. When the checker finds a free slot it inserts an APPOINTMENT_FOUND row into the notification outbox, unless one is already pending, and sends NOTIFY. The Telegram bot wakes on the notification or polls every 20 seconds, claims pending rows with SKIP LOCKED, and messages each subscriber who is outside their cooldown, recording every delivery. After three failed attempts a row is marked failed and the admin is told.

  1. /subscribe

    telegram

    Name, then an email address

  2. Domain and capacity check

    bot

    Allowed domains only, capped active subscriptions

  3. One-time code by email

    amazon ses

  4. /confirm

    telegram

    Subscribed for five days, then expires automatically

Fig. 2 · Subscription flow. Only verified addresses on allowed domains can subscribe.
Diagram description

A user sends /subscribe to the Telegram bot and enters a name and an email address. The bot checks that the domain is allowed and that the subscription cap isn't reached, then sends a one-time code through Amazon SES. After the user confirms the code, the subscription is active for five days.

Decision

Put a transactional outbox in PostgreSQL

The checker only inserts an APPOINTMENT_FOUND row (unless one is already pending) and the bot owns delivery. Each process can fail without the other noticing.

Considered instead

  • Calling the Telegram API straight from the checkerAn alert raised during a bot outage, or a crash mid-loop, is lost or repeated.

Decision

Wake the bot with LISTEN/NOTIFY, and poll every 20 seconds as a fallback

NOTIFY makes delivery near-instant, and the polling loop covers anything a dropped connection misses.

Considered instead

  • A message broker such as Redis or RabbitMQOne more service to run for a single queue that PostgreSQL already provides.

Decision

Claim rows with SKIP LOCKED and record every delivery

Each delivery is written to an alert_delivery table, so a retried row skips subscribers who were already alerted. After three failed attempts the row is marked failed and the admin is told.

Considered instead

  • Retrying the whole fan-out on failureEveryone who already got the alert would get it again.

Decision

Drive a real browser with Selenium

Headless Chrome with images disabled keeps memory low. If a click is intercepted, a JavaScript click fallback keeps the flow moving.

Considered instead

  • Replaying the booking flow with plain HTTP requestsThe flow spans several pages, and replaying it means rebuilding its state by hand.

Results

41

unit tests against a real PostgreSQL database

Outbox claims, retries, stuck-row recovery, cooldowns, the subscription flow and Selenium fallbacks

≤1

alert per subscriber per cooldown window

Enforced by the alert_delivery table

30 days

before outbox and telemetry rows are deleted

Cleaned up nightly by the bot

Version 1.0.0 is released on GitHub. It runs under Docker Compose with a heartbeat health check, or on a schedule in GitHub Actions.

What I'd do differently

  • Treat a broken booking flow as its own alert. Selectors depend on the target page, and a silent layout change should never look like "no appointments today".
  • Measure time-to-alert, from the slot appearing to the message arriving. It is the one number subscribers care about.