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:

RegimeTriggerPayoutDeadline
EU2613h+ arrival delay, cancellation <14 days notice, denied boarding€250 / €400 / €600 by distance1–5 yrs by country
UK261Same triggers, post-Brexit£220 / £350 / £5206 yrs
Canada APPR3/6/9h controllable delay, large carrierC$400 / C$700 / C$1,0002 yrs (1–2 by province)
US DOTCancellation or 3h domestic / 6h international delayFull cash refund of the ticket + ancillary feesNo fixed comp, but the refund itself is often hundreds of dollars
Brazil ANAC 4004h+ delay or cancellationRebooking/refund + material assistance; denied boarding in SDRsCase-by-case

Three structural facts make this exploitable:

  1. 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.

  2. 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.

  3. 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:

  1. 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).

  2. Send to the airline’s claims address, not the contact form. Forms generate auto-rejections; email creates a paper trail.

  3. 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.

  4. 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

  1. Run the watcher mock against your inbox structure to sanity-check the parser, then wire up live OAuth.
  2. Feed every extracted booking into the screener with correct distances (any great-circle calculator; the engine takes distance_km as input so you can batch-lookup with a geonames CSV).
  3. Sort by deadline, confirm actual arrival delays against flight-tracking archives, and file the top five this week.
  4. 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.