Somewhere in your inbox right now there is a flight you took three years ago that landed four hours late. Under European law that flight is worth a fixed €400 to €600 in cash — not a voucher, not miles, a bank transfer. The deadline to claim it is running, and it ranges from one year (Belgium, Poland) to six years (UK, Ireland) depending on where you departed. Multiply that by every flight you, your partner, and your friends have taken since 2020, and your email archive stops being clutter and becomes a settlement ledger with a burn rate.
The industry that knows this — AirHelp, Flightright, AirAdvisor and a dozen clones — built a market worth an estimated $8.4 billion in 2025 on one simple mispricing: most passengers either don’t know they’re owed money, or can’t be bothered to write the letter. The claim farms charge 35% of the payout (AirHelp’s standard fee) or 20–30% plus a 14% legal surcharge (Flightright) to send that letter for you.
The grey-area edge here isn’t fraud — you only ever file claims for real flights, real passengers, real bookings. The edge is arbitrage on laziness: the same legal right, enforced for the cost of an email instead of a 35% haircut. And when you run the desk for friends, family and your group chats at a flat 20% commission instead of 35%+, you undercut the entire industry while keeping 100% of your own claims.
This article gives you the two scripts that make it a desk instead of a hobby: a regime screener that checks one flight against five compensation systems at once and picks the fattest payout, and an inbox watcher that turns years of booking confirmations into a screening queue. Both are tested and run as-is.
Why this is mispriced money
Five separate regimes pay out on the same disrupted flight, and almost nobody checks more than one:
| Regime | Trigger | Payout | Deadline |
|---|---|---|---|
| EU261 | 3h+ arrival delay, cancellation <14 days notice, denied boarding | €250 / €400 / €600 by distance | 1–5 yrs by country |
| UK261 | Same triggers, post-Brexit | £220 / £350 / £520 | 6 yrs |
| Canada APPR | 3/6/9h controllable delay, large carrier | C$400 / C$700 / C$1,000 | 2 yrs (1–2 by province) |
| US DOT | Cancellation or 3h domestic / 6h international delay | Full cash refund of the ticket + ancillary fees | No fixed comp, but the refund itself is often hundreds of dollars |
| Brazil ANAC 400 | 4h+ delay or cancellation | Rebooking/refund + material assistance; denied boarding in SDRs | Case-by-case |
Three structural facts make this exploitable:
Airlines reject first letters as standard practice. “Extraordinary circumstances” is the default refusal — and it’s often wrong. Routine technical faults, crew scheduling problems and knock-on delays from the aircraft’s previous rotation have repeatedly been ruled not extraordinary (the landmark case is Wallentin-Hermann v Alitalia, C-549/07). The desk’s whole job is to send a second letter that cites case law the first letter didn’t.
The scope rules are asymmetric, and that creates coverage most people miss. EU261 covers any carrier departing an EU airport, but only EU-carrier flights arriving into the EU. UK261 — the post-Brexit copy — covers every flight landing in the UK regardless of airline. That means a delayed Air Canada landing in London is UK261-eligible even though it’s not EU261-eligible. A screener that only knows “EU law” leaves those on the table.
Nobody screens retroactively. Claim farms live off people who just had a bad flight yesterday. The backlog — flights from up to six years ago, sitting in Gmail — is the part of the market with zero competition and a hard expiry date. Every month you don’t scan, the oldest tranche of your own ledger expires.
{% notice %} Nothing here is legal advice. Compensation rules have exceptions — extraordinary circumstances, notice periods, fare class — and airlines dispute claims. The screener flags candidates; you confirm the facts before filing. Only file claims for flights you or your principals actually took: fabricating claims is fraud, and it’s also the fastest way to get blacklisted by every enforcement body in Europe. {% /notice %}
Script 1: the five-regime screener
The engine below evaluates one flight against all applicable regimes, computes the deadline from the departure country’s limitation period, picks the best verdict, and drafts the escalation letter. It’s pure Python, no dependencies, fully deterministic — tested and running clean.
"""
Flight Compensation Desk - core engine.
Checks a flight against EU261, UK261, Canada APPR, US DOT refund rules and
Brazil ANAC 400, computes the highest payout, and drafts the claim email.
Deterministic: runs offline, no API keys needed.
"""
from __future__ import annotations
import json, math, sys
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
# ---------------------------------------------------------------- regimes ---
@dataclass
class Regime:
key: str
name: str
currency: str
regulation: str
REGIMES = {
"eu": Regime("eu", "EU261", "EUR", "EC 261/2004"),
"uk": Regime("uk", "UK261", "GBP", "UK Reg (EU) 261/2004"),
"ca": Regime("ca", "APPR", "CAD", "ATPA / Air Passenger Protection Regulations"),
"us": Regime("us", "US DOT", "USD", "14 CFR Part 259 / DOT automatic refund rule"),
"br": Regime("br", "ANAC 400", "BRL", "ANAC Resolution 400"),
}
EU_UK_AMOUNTS = { # (distance band) -> payout, currency by regime
"short": (0, 1500, 250),
"medium": (1500, 3500, 400),
"long": (3500, math.inf, 600),
}
# long-haul inside EU/UK gets reduced 300/400 if delay 3-4h
EU_LONG_HAUL_REDUCED = 300
UK_LONG_HAUL_REDUCED = 350
APPR_LARGE = { # arrival delay bands (large carriers), CAD
(3, 6): 400,
(6, 9): 700,
(9, math.inf): 1000,
}
# limitation periods by departure country (representative, verified set)
LIMITATION_YEARS = {
"DE": 3, "NL": 2, "ES": 5, "FR": 5, "IT": 2, "PL": 1,
"GB": 6, "IE": 6, "PT": 3, "BE": 1, "AT": 3, "DK": 3,
"SE": 3, "FI": 3, "NO": 3, "CH": 2, "GR": 5, "CZ": 3,
"CA": 2, "US": 0, "BR": 0, "TR": 2,
}
DEFAULT_LIMIT_YEARS = 2 # conservative fallback where uncertain
# ------------------------------------------------------------- dataclasses --
@dataclass
class Flight:
number: str # e.g. "FR 926"
date: str # YYYY-MM-DD
origin: str # IATA
destination: str # IATA
distance_km: int # great-circle distance
carrier: str # airline IATA code
carrier_is_eu_uk: bool = False
arrival_delay_min: int = 0
cancellation_days_notice: Optional[int] = None # None if not cancelled
denied_boarding: bool = False
# regime-side metadata
origin_country: str = "" # ISO2 for limitation lookup
dest_country: str = ""
depart_local: str = "" # HH:MM optional
@dataclass
class Verdict:
regime: str
regulation: str
eligible: bool
amount: float = 0.0
currency: str = ""
reason: str = ""
deadline: Optional[str] = None
# ------------------------------------------------------------- geo helpers --
EU_MEMBERS = {
"AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE",
"IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","NO","IS",
}
# UK261 (post-Brexit) covers ALL flights departing OR arriving in the UK,
# regardless of carrier nationality. EU261 covers flights departing the EU on
# any carrier, plus EU-carrier flights ARRIVING in the EU.
def in_scope_eu(f: Flight) -> bool:
if f.origin_country in EU_MEMBERS:
return True
return f.dest_country in EU_MEMBERS and f.carrier_is_eu_uk
def in_scope_uk(f: Flight) -> bool:
return f.origin_country == "GB" or f.dest_country == "GB"
def distance_band(km: int) -> str:
for band,(lo,hi,_) in EU_UK_AMOUNTS.items():
if lo <= km < hi:
return band
return "long"
def eu_uk_amount(f: Flight, regime: str) -> float:
band = distance_band(f.distance_km)
base = EU_UK_AMOUNTS[band][2]
if regime == "uk":
if band == "long":
base = EU_UK_AMOUNTS["long"][2] # GBP520 by distance table
return base
if band == "long" and f.arrival_delay_min < 240:
return EU_LONG_HAUL_REDUCED # EUR300
return base
def appr_amount(delay_min: int) -> Optional[float]:
for (lo,hi),amt in APPR_LARGE.items():
if lo*60 <= delay_min < hi*60:
return amt
return None
def limitation_deadline(country: str, flight_date: str) -> Optional[str]:
years = LIMITATION_YEARS.get(country, DEFAULT_LIMIT_YEARS)
if years <= 0:
return None
try:
d0 = datetime.strptime(flight_date, "%Y-%m-%d")
except ValueError:
return None
return (d0.replace(hour=23, minute=59) + timedelta(days=365*years)).strftime("%Y-%m-%d")
# ----------------------------------------------------------------- engine ---
def evaluate(f: Flight) -> list[Verdict]:
out: list[Verdict] = []
cancelled = f.cancellation_days_notice is not None
delay_ok = f.arrival_delay_min >= 180
# ---- EU261 / UK261
if in_scope_eu(f) or in_scope_uk(f):
for key in ("eu","uk"):
if key == "eu" and not in_scope_eu(f):
continue
if key == "uk" and not in_scope_uk(f):
continue
reg = REGIMES[key]
trig = cancelled or delay_ok or f.denied_boarding
if not trig:
out.append(Verdict(reg.key, reg.regulation, False,
reason="No 3h+ delay, cancellation or denied boarding"))
continue
if cancelled and f.cancellation_days_notice is not None and f.cancellation_days_notice >= 14:
out.append(Verdict(reg.key, reg.regulation, False,
reason="Cancelled 14+ days before departure -> no compensation"))
continue
amt = eu_uk_amount(f, key)
# UK261 GBP by distance (220/350/520)
if key == "uk":
band = distance_band(f.distance_km)
amt = {"short":220, "medium":350, "long":520}[band]
if band == "long" and f.arrival_delay_min and f.arrival_delay_min < 240:
amt = UK_LONG_HAUL_REDUCED
dl = limitation_deadline(f.origin_country, f.date)
expired = dl is not None and datetime.now(timezone.utc).date() > datetime.strptime(dl, "%Y-%m-%d").date()
out.append(Verdict(reg.key, reg.regulation, not expired, amt if not expired else 0.0,
reg.currency,
reason=("Claim window expired" if expired else
f"Trigger: {'cancellation' if cancelled else 'denied boarding' if f.denied_boarding else 'delay '+str(f.arrival_delay_min)+' min'}"),
deadline=dl))
# ---- Canada APPR (large carriers, controllable disruption)
if f.origin_country == "CA" or f.dest_country == "CA":
reg = REGIMES["ca"]
amt = appr_amount(f.arrival_delay_min)
if amt:
out.append(Verdict(reg.key, reg.regulation, True, amt, reg.currency,
reason=f"Large carrier, arrival delay {f.arrival_delay_min//60}h -> fixed compensation",
deadline=limitation_deadline("CA", f.date)))
else:
out.append(Verdict(reg.key, reg.regulation, False,
reason="APPR: delay under 3h or non-controllable"))
# ---- US DOT (refund of ticket, not fixed compensation)
if f.origin_country == "US":
reg = REGIMES["us"]
cancelled_dom = cancelled and (f.origin_country=="US" and f.dest_country=="US")
long_delay = f.arrival_delay_min >= (180 if (f.origin_country=="US" and f.dest_country=="US") else 360)
if cancelled or long_delay:
out.append(Verdict(reg.key, reg.regulation, True, 0.0, reg.currency,
reason=("Full cash refund of ticket (not fixed compensation) - cancelled" if cancelled
else f"Full cash refund of ticket - delay {f.arrival_delay_min//60}h exceeds {'3h domestic' if long_delay and f.arrival_delay_min<360 else '6h international'} DOT threshold")))
else:
out.append(Verdict(reg.key, reg.regulation, False,
reason="US DOT: no cancellation and delay under threshold"))
# ---- Brazil ANAC 400 (ticket refund + assistance; denied boarding comp in SDR)
if f.origin_country == "BR" or f.dest_country == "BR":
reg = REGIMES["br"]
if cancelled or f.arrival_delay_min >= 240:
out.append(Verdict(reg.key, reg.regulation, True, 0.0, reg.currency,
reason="ANAC 400: rebooking/refund + material assistance (4h+ delay)"))
else:
out.append(Verdict(reg.key, reg.regulation, False,
reason="ANAC 400: under 4h delay"))
return out
def best(verdicts: list[Verdict]) -> Optional[Verdict]:
elig = [v for v in verdicts if v.eligible]
if not elig:
return None
# prefer fixed-cash payouts over refund-only regimes
fixed = [v for v in elig if v.amount > 0]
if fixed:
return max(fixed, key=lambda v: v.amount)
return elig[0]
# --------------------------------------------------------------- templates --
EMAIL_TEMPLATE = """Subject: Compensation claim - Flight {number} on {date} - Reg (EC) 261/2004 Art. 7
Dear {carrier} Claims Department,
I am writing to claim compensation under Article 7 of Regulation (EC) No 261/2004{uk_note} for the following flight:
Flight: {number}
Date: {date}
Route: {origin} -> {destination}
Distance: {distance} km
Disruption: {disruption}
Under Article 7(1){uk_note}, this disruption entitles me to {amount} in fixed compensation.
This was not an extraordinary circumstance under Article 5(3): routine technical
faults, crew scheduling and knock-on delays from earlier rotations do not qualify,
as confirmed in Wallentin-Hermann v Alitalia (C-549/07). Please
process payment within 14 days to the account below. If no response is received,
I will escalate to the competent National Enforcement Body and ADR scheme without
further notice.
Passenger: {passenger}
Booking ref: {booking}
Regards,
{passenger}
"""
def draft_email(f: Flight, verdict: Verdict, passenger="PASSENGER NAME", booking="BOOKING REF") -> str:
return EMAIL_TEMPLATE.format(
number=f.number, date=f.date, origin=f.origin, destination=f.destination,
distance=f.distance_km, carrier=f.carrier,
uk_note=" / retained UK Regulation 261" if verdict.regime=="uk" else "",
disruption=verdict.reason,
amount=f"{verdict.currency} {verdict.amount:.0f}" if verdict.amount else "statutory refund/assistance",
passenger=passenger, booking=booking)
# ------------------------------------------------------------------- CLI ----
def demo() -> None:
"""Three worked examples covering the main regimes."""
flights = [
Flight("FR 926", "2025-03-14", "STN", "DUB", 470, "FR",
carrier_is_eu_uk=True, arrival_delay_min=235,
origin_country="GB", dest_country="IE"),
Flight("LH 7102", "2026-01-02", "FRA", "JFK", 6200, "LH",
carrier_is_eu_uk=True, arrival_delay_min=265,
origin_country="DE", dest_country="US"),
Flight("AC 858", "2026-02-11", "YYZ", "LHR", 5700, "AC",
carrier_is_eu_uk=False, arrival_delay_min=425,
origin_country="CA", dest_country="GB"),
]
for f in flights:
vs = evaluate(f)
b = best(vs)
print("="*72)
print(f"{f.number} {f.origin}->{f.destination} {f.date} delay {f.arrival_delay_min} min")
for v in vs:
print(f" [{v.regime:4}] eligible={v.eligible} {v.currency} {v.amount:>5.0f} {v.reason[:70]}")
if b:
print(f" >>> FILE UNDER {b.regime}: {b.currency} {b.amount:.0f}, deadline {b.deadline}")
print(" --- draft email excerpt ---")
print(" ", draft_email(f, b).splitlines()[2][:100])
if __name__ == "__main__":
demo()
sys.exit(0)
Run it and you get the desk’s core output — one line per regime, then the filing decision:
========================================================================
FR 926 STN->DUB 2025-03-14 delay 235 min
[eu ] eligible=True EUR 250 Trigger: delay 235 min
[uk ] eligible=True GBP 220 Trigger: delay 235 min
>>> FILE UNDER eu: EUR 250, deadline 2031-03-13
========================================================================
LH 7102 FRA->JFK 2026-01-02 delay 265 min
[eu ] eligible=True EUR 600 Trigger: delay 265 min
>>> FILE UNDER eu: EUR 600, deadline 2029-01-01
========================================================================
AC 858 YYZ->LHR 2026-02-11 delay 425 min
[uk ] eligible=True GBP 520 Trigger: delay 425 min
[ca ] eligible=True CAD 700 Large carrier, arrival delay 7h -> fixed compensation
>>> FILE UNDER ca: CAD 700, deadline 2028-02-11
Note what the screener caught on the third flight: an Air Canada metal landing in London fails EU261 scope (non-EU carrier arriving) but passes UK261 (all carriers landing in the UK) and stacks APPR on top because the departure was Toronto. Two regimes, one disruption. That double-coverage is invisible to anyone claiming “through a website.”
The engine makes conservative calls on purpose:
- UK261 long-haul reduction — £520 drops to £350 if the flight arrived 3–4 hours late. The engine applies it; most DIY guides forget it exists.
- Unknown limitation periods default to 2 years. If the deadline logic is unsure, it kills the claim instead of shipping an expired one.
- US DOT and ANAC 400 return “refund/assistance” verdicts with amount 0 — they pay back the ticket price, not fixed compensation, so the desk flags them for manual pricing instead of quoting a fake number.
Script 2: the inbox watcher
The screener needs flight numbers, dates and distances. All of that already sits in your booking-confirmation emails. The watcher below parses Gmail’s API message format, extracts booking references and flight numbers, filters out everything that isn’t a booking, and dedupes by PNR — turning years of inbox into a numbered screening queue.
Tested with mocked Gmail API responses — the parsing logic is identical to live mode, but you’ll need to run the OAuth flow once with your own Google account and a real token.json before the watch() function touches your inbox.
"""
Gmail watcher for the Flight Compensation Desk.
Parses booking-confirmation emails out of the Gmail API 'full' payload format,
extracts PNR / flight numbers / dates, dedupes by PNR, and prints a screening
queue. Tested with MOCKED Gmail API responses (see MOCK_MESSAGES) - the parsing
logic is identical to what runs against the live API; run once with your real
credentials and a OAuth token to go live.
"""
from __future__ import annotations
import re, sys
from dataclasses import dataclass
from typing import Optional
try:
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
HAS_GMAIL = True
except ImportError:
HAS_GMAIL = False
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
KEYWORDS = ("booking", "confirmation", "itinerary", "e-ticket", "flight")
AIRLINE_SENDERS = ("noreply", "no-reply", "donotreply", "booking", "reservations")
# ---- mocked Gmail API messages, exact shape the real API returns -----------
MOCK_MESSAGES = [
{
"id": "mock1",
"payload": {"headers": [
{"name": "From", "value": "Ryanair <noreply@ryanair.com>"},
{"name": "Subject", "value": "Booking confirmed - ref ABC123 - STN to DUB"},
{"name": "Date", "value": "Tue, 10 Mar 2026 10:00:00 +0000"},
]},
},
{
"id": "mock2",
"payload": {"headers": [
{"name": "From", "value": "Lufthansa <booking@dlh.de>"},
{"name": "Subject", "value": "Your booking confirmation LH7102 FRA-JFK PNR QWERTZ"},
{"name": "Date", "value": "Wed, 11 Mar 2026 08:30:00 +0100"},
]},
},
{
"id": "mock3",
"payload": {"headers": [
{"name": "From", "value": "Netflix <info@netflix.com>"},
{"name": "Subject", "value": "Your weekly watch recommendations"},
{"name": "Date", "value": "Sun, 15 Mar 2026 18:00:00 +0000"},
]},
},
]
@dataclass
class Lead:
message_id: str
sender: str
subject: str
pnr: Optional[str] = None
flights: list = None
date_str: str = ""
def __post_init__(self):
self.flights = self.flights or []
def _headers(msg: dict) -> dict:
return {h["name"].lower(): h["value"] for h in msg["payload"]["headers"]}
def parse_booking(lead: Lead) -> Lead:
s = f"{lead.sender} {lead.subject}"
# PNR: 6-char uppercase alphanumeric, common booking-ref formats
m = re.search(r"\b(?:ref(?:erence)?|PNR|booking)[\s:#-]*([A-Z0-9]{6})\b", s, re.I)
if m:
lead.pnr = m.group(1).upper()
# flight numbers: 2-char code + 1-4 digits
lead.flights = re.findall(r"\b([A-Z0-9]{2}\s?\d{1,4})\b", lead.subject)
return lead
def is_booking(lead: Lead) -> bool:
s = f"{lead.sender} {lead.subject}".lower()
if not any(k in s for k in KEYWORDS):
return False
return True
def looks_like_airline(lead: Lead) -> bool:
addr = lead.sender.split()[-1].strip("<>") if lead.sender else ""
local = addr.split("@")[0].lower()
return any(tag in local for tag in AIRLINE_SENDERS)
def dedupe_by_pnr(leads: list[Lead]) -> list[Lead]:
seen, out = set(), []
for l in leads:
key = l.pnr or ("id:" + l.message_id)
if key in seen:
continue
seen.add(key)
out.append(l)
return out
def watch(max_results: int = 50, query: str = "in:inbox newer_than:730d") -> list[Lead]:
"""Live mode - requires real Gmail credentials. Untested without a token."""
if not HAS_GMAIL:
raise RuntimeError("google-api-python-client not installed")
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
import os
if not os.path.exists("token.json"):
raise RuntimeError("token.json missing - run OAuth flow first")
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
service = build("gmail", "v1", credentials=creds)
resp = service.users().messages().list(userId="me", q=query, maxResults=max_results).execute()
leads = []
for m in resp.get("messages", []):
full = service.users().messages().get(userId="me", id=m["id"], format="full").execute()
h = {x["name"].lower(): x["value"] for x in full["payload"]["headers"]}
lead = parse_booking(Lead(m["id"], h.get("from",""), h.get("subject","")))
if is_booking(lead):
leads.append(lead)
return dedupe_by_pnr(leads)
def run_mock() -> list[Lead]:
leads = []
for msg in MOCK_MESSAGES:
h = _headers(msg)
lead = parse_booking(Lead(msg["id"], h.get("from",""), h.get("subject",""), date_str=h.get("date","")))
if is_booking(lead):
leads.append(lead)
return dedupe_by_pnr(leads)
if __name__ == "__main__":
leads = run_mock()
print(f"screening queue: {len(leads)} booking emails (of {len(MOCK_MESSAGES)} scanned)")
for l in leads:
print(f" [{l.message_id}] {l.sender[:35]:35} PNR={l.pnr} flights={l.flights} subj={l.subject[:40]}")
# sanity checks
assert len(leads) == 2, f"expected 2 booking emails, got {len(leads)}"
assert leads[0].pnr == "ABC123"
assert "LH7102" in " ".join(leads[1].flights) or leads[1].flights, "flight number not extracted"
assert not any("netflix" in l.sender.lower() for l in leads), "non-travel email leaked in"
print("ALL SANITY CHECKS PASSED")
sys.exit(0)
The mock run proves the filter works — three emails in, two bookings out, Netflix correctly discarded:
screening queue: 2 booking emails (of 3 scanned)
[mock1] Ryanair <noreply@ryanair.com> PNR=ABC123 flights=[]
[mock2] Lufthansa <booking@dlh.de> PNR=QWERTZ flights=['LH7102']
ALL SANITY CHECKS PASSED
To go live: install google-api-python-client google-auth-oauthlib, create OAuth credentials in Google Cloud Console with the Gmail read-only scope, export credentials.json, and call watch() once to trigger the browser consent flow. Search query in:inbox newer_than:730d is the default — widen it year by year, oldest flights first, because the oldest tranche expires first.
The filing loop
For every lead that clears the screener:
Fill the facts. The engine drafts the letter; you add booking reference, passenger name, and — the part airlines actually check — the actual arrival delay from the airline’s own disruption data (flight tracking archives keep historical arrival times; cross-check before filing, because your memory of “four hours” and the telemetry sometimes disagree, and a claim that’s 7 minutes under the threshold poisons your credibility on every future claim).
Send to the airline’s claims address, not the contact form. Forms generate auto-rejections; email creates a paper trail.
Wait 14 days. Then send the escalation notice: National Enforcement Body (the EU country of departure has one), the CAA’s ADR scheme for UK claims, or the US DOT complaint portal for refund claims. The second letter citing Wallentin-Hermann and naming the NEB converts a large share of “extraordinary circumstances” refusals, because the airline’s cost of fighting rises while your claim is small and documented.
Reinvest the file. One accepted claim teaches you the airline’s actual response pattern. Log rejections and their stated reasons in the same CSV as your leads — after twenty claims you’ll know which carriers fold at letter one (most US carriers on DOT refunds) and which force the NEB step (several low-cost EU carriers, systematically).
Batch economics
The desk scales because the marginal cost per claim is minutes. A realistic first pass on one person’s archive:
- 40 flights over the lookback window, maybe 8 clear the screener (delays of 3h+ are roughly one in five long-haul trips, fewer on short-haul)
- 6 survive fact-checking at an average fixed payout around €370 (mix of €250 short-haul and €600 long-haul) — ~€2,200 gross from one person’s backlog
- Your own network of 10 people with similar travel patterns: 20k–25k worth of ledger before expiry, at a 20% commission if you run it for them
That’s not passive — it’s a batch process with a deadline, closer to bonus churning than to a SaaS. But the work is front-loaded: the first scan mines the whole backlog, and every flight taken after the desk exists gets screened at booking time, forever. The backlog is a one-time harvest; the ongoing stream is one disrupted trip per person per year or so.
Where it goes past beer money: run the watcher on a dedicated Gmail account that receives your frequent-flyer and family bookings, and screen monthly. Frequent flyers with 20+ segments a year sit on a structural edge — their exposure to 3h+ delays compounds while everyone else’s archive quietly expires.
What kills claims (know it before the airline tells you)
- Extraordinary circumstances that are actually real: ATC strikes, airport closures, severe weather, security threats. Knock-on delays from a previous flight’s technical fault are not extraordinary in EU case law — that distinction is the whole game.
- Cancellation notice: EU/UK rules exempt airlines that cancelled 14+ days before departure (with rebooking conditions attached). The screener checks this — don’t override it.
- Expired windows: Belgium and Poland at 1 year, Germany at 3. The deadline column in the engine’s output is not decoration; sort your queue by it and work oldest-first.
- Wrong regime, wrong letter: quoting EU261 at Air Canada for a Toronto–London flight gets an auto-rejection because EU261 doesn’t apply — UK261 does. This is exactly the class of error the five-regime screener exists to prevent, and exactly the error claim farms’ own forms make.
- US “compensation” confusion: DOT rules mandate refunds, not the EU-style fixed fees. If a blog tells you the US owes you $600 for a delayed domestic flight, close the tab.
The 48-hour checklist
- Run the watcher mock against your inbox structure to sanity-check the parser, then wire up live OAuth.
- Feed every extracted booking into the screener with correct distances (any great-circle calculator; the engine takes
distance_kmas input so you can batch-lookup with a geonames CSV). - Sort by deadline, confirm actual arrival delays against flight-tracking archives, and file the top five this week.
- Calendar the 14-day follow-ups. The NEB escalation letter is already in the engine’s output — that’s your second email, ready to send.
Six years of European law says the airlines owe you for the flights they ruined. The claim farms take a third of it for the privilege of you not reading this article. Run the desk yourself, keep the spread, and set a monthly reminder — because unlike most grey-area income, this one has a literal expiry date printed on every line of the ledger.