What these tools are
The stack is built from off-the-shelf services wired together with Python:
- Smoobu — Channel manager and property-management system: centralizes reservations from Booking.com, Airbnb, etc., and stores guest names, dates, and prices.
- Nuki — Smart lock (keypad + app): the API creates and deletes time-bound PINs so guests and the cleaning crew can enter without physical keys; lock logs show who entered and when.
- Cebelca — Slovenian invoicing platform: API is used to create partners (guests), issue invoices, and finalize them for compliance.
- CallMeBot + SES — WhatsApp and email alerting (e.g. to the cleaning service when no cleaner has entered the apartment by a set time before check-in), with all-clear messages when a problem self-heals.
- Check-in scan — Identity verification provider: guest document scans are matched to the reservation, de-duplicated in DynamoDB, and synced with Smoobu.
- eTurizem — Slovenian national tourism/guest registry (AJES): accommodation providers must report guest data by law. The automation generates the required XML (
knjigaGostov) from check-in data and submits it automatically via the official SOAP API (client-cert authenticated).
Project overview — event-driven serverless
A booking arrives as a Smoobu webhook (API Gateway → Lambda) and upserts reservation state into DynamoDB.
From there everything is autonomous: two days before arrival the stack creates and fiscalises the invoice,
creates a time-bound Nuki keypad PIN and publishes it into the guest's automated welcome message; on arrival
day it arms the building intercom into continuous mode and polls for the guest's first keypad entry; guest ID
scans are filed to the Slovenian eTurizem registry over mTLS SOAP; the monthly statutory return files itself;
and a linen-inventory forecast texts the laundry pickup date when it changes. Every time-based job is an
EventBridge schedule with a real timezone (Europe/Berlin) so DST is AWS's problem, not manual
UTC arithmetic.
Reservations & smart lock
- Smoobu webhooks upsert reservation state into DynamoDB; an hourly sync reconciles drift and cancellations.
- Creates 6-digit Nuki PINs for guests in the arrival window; deletes expired PINs after departure.
- Writes PINs into Smoobu custom placeholders so guests see them in confirmations.
- Uses Nuki lock logs to detect first keypad entry and trigger check-in/cleaning flows.
Identity, billing & ops
- Check-in scan polling: identity-verification API, guest de-duplication in DynamoDB, and Smoobu sync.
- Regulatory guest reporting: XML generation and automatic reporting to the Slovenian eTurizem platform (AJES). After check-in, guest data from the identity scan is built into the required
knjigaGostovXML format and submitted via the official SOAP API so the stay is registered for compliance. - Invoicing via Cebelca API (partner ensure, invoice-sent, line items, finalize).
- Cleaning alerts: on check-in days a scheduled check reads the Nuki lock logs; if no cleaning-service entry is recorded by a set time before check-in, the cleaning boss gets a WhatsApp/email alert so they can chase the cleaner.
- Structured logging in CloudWatch; every external call has fallbacks and alert-latched error handling with all-clears.
- Invoice PDFs archived to S3; all tokens and the mTLS client certificate live in Secrets Manager.
Engineering highlights
The interesting parts came from real incidents — each fix is now a permanent property of the system.
Trust-nothing device control
Nuki's action endpoint returns "accepted", not "applied" — and one accepted command that never reached the intercom locked a guest out of the building for hours. The check-in monitor now reads state back from the device and re-asserts it on every 5-minute run until the guest is detected, so a dropped command self-heals in ≤5 minutes instead of never.
Two independent check-in signals
Nuki's cloud log was observed silently dropping keypad entries the lock itself recorded. A fallback reads the keypad code's usage counter from the live auth list — keyed by PIN code rather than auth id, because ids change when a lost PIN is recreated.
Atomic claims, not read-then-write
Every "send this once" decision (laundry forecasts, reminders, monthly statutory report, guest de-dup)
is a DynamoDB conditional write (attribute_not_exists), so a concurrent webhook and a
scheduled run can never double-send or double-file. The statutory sequence number is an atomic counter.
Alert storms & all-clears
Alerts are one-shot latches persisted in DynamoDB, re-armed on recovery and bounded, so a flapping device can't email every 5 minutes — and an all-clear is sent on self-heal, because previously the alert was the last thing you ever heard.
Reconciliation as a safety net
When the cancellation webhook silently stopped matching for weeks, ghost bookings kept being invoiced and PIN'd. The hourly sync now reconciles cancellations too — hardened so a failed Smoobu fetch can never masquerade as "everything was cancelled".
Structural secret containment
The repo's early history contains an mTLS private key, so publication is guarded twice: a pre-push hook rejects any ref whose history ever touched key files, and a mirror-publisher script credential-sweeps the tree and force-pushes a single parentless squashed commit. The leak is impossible, not remembered.
Representative code excerpts
Self-healing device control and timezone-correct serverless scheduling.
Re-assert until the guest is in
def _ensure_cm_armed(record, name):
"""Make sure the Opener really is in continuous mode - re-checked EVERY run."""
if not record.get("todayCheckin"):
store.set_flag(record["reservationId"], todayCheckin=True)
record["todayCheckin"] = True
if nuki.cm_is_active(): # read back from the device, never assume
_cm_armed_ok(record, name)
return
if nuki.set_cm(True):
_cm_armed_ok(record, name)
return
_alert_once(record, "Continuous Mode not active - action needed", ...)
Scheduling with a real timezone
CheckinMonitorFn:
Type: AWS::Serverless::Function
Properties:
Handler: handlers.checkin_monitor.handler
Events:
Every5Min:
Type: ScheduleV2
Properties:
ScheduleExpression: 'cron(0/5 8-22 * * ? *)'
ScheduleExpressionTimezone: Europe/Berlin # DST handled by AWS