EPO Register Headless Browser Fallback: Implementation Guide for Patent Docketing

The European Patent Register is the authoritative ledger for procedural milestones, fee obligations, and legal-status transitions across every EPC contracting state, and a docketing system computes irreversible deadlines — the Article 99(1) EPC opposition window, the non-extendable Rule 71(3) EPC response period — directly from the events it publishes. The sanctioned machine-readable channel is the Open Patent Services (OPS) REST API, and the primary integration against it is the EPO Register Sync Architecture pipeline; this page solves the narrower problem of what a docketing platform does when that API degrades — returning 429/5xx under quota pressure, lagging behind publication, or omitting a deadline-bearing field — and register state must still be reconstructed with the same determinism and audit discipline.

A headless browser fallback is a strictly secondary ingestion vector within the broader Patent Office Portal Sync & Data Ingestion layer. It exists to keep deadline calculation alive during API outages, not to replace the API, and it inherits every constraint the primary path enforces: extracted fields are validated before they touch arithmetic, every invocation is logged to an append-only ledger, and any record that fails validation is quarantined for paralegal review rather than silently guessed at.

Circuit-breaker routing between the OPS API and the headless fallback The OPS REST API probe feeds a circuit breaker. When the API is healthy the USE_API route flows to the Pydantic validation gate. On a 429 or 5xx after three retries, a null deadline-bearing field, or publication lag beyond fourteen days, the breaker trips to the FALLBACK route, which drives a headless Playwright session against the register.epo.org UI, extracts the DOM, and converges on the same validation gate. A 403 or CAPTCHA takes the MANUAL route straight to a human ingestion queue and never reaches the browser. The validation gate emits valid records carrying a SHA-256 audit hash to the canonical record and sends invalid records to quarantine. OPS REST API register / procedural-steps CIRCUIT BREAKER 429 / 5xx · 3 retries null deadline field publication lag > 14d 403 → manual, never scrape Headless Playwright register.epo.org UI → DOM 3s throttle · assets blocked Pydantic v2 gate schema · row shape reject future dates data minimization Canonical RegisterRecord + SHA-256 audit idempotency key Quarantine → paralegal review Manual ingestion queue human review — never evade USE_API · healthy FALLBACK 403 / CAPTCHA valid invalid USE_API and FALLBACK converge on one validation gate

Compliance & Scope Boundaries

The EPO permits programmatic access through OPS under its published fair-use terms; it does not sanction automation that degrades the human-facing Register web application or circumvents access controls. The fallback therefore operates inside a tight envelope, and these boundaries belong in code review before anything ships:

  • API-first, always. The fallback may only fire after the OPS path has genuinely failed. Preferring the browser because it is easier to write is a terms-of-service violation and multiplies both compute cost and fingerprinting exposure. Throughput management on the primary path lives in the dedicated EPO Register API Rate Limiting Strategies guide, which the breaker below assumes is already in force.
  • Honor robots.txt and throttle hard. Retrieval from register.epo.org must respect the site’s robots.txt directives and enforce a minimum 3-second delay between sequential requests, with no more than one in-flight request per application number.
  • Data minimization. Extract only the fields required for deadline calculation and fee tracking. Applicant names, inventor addresses, and representative details are personal data under the GDPR; strip or gate them per Security & Access Control Boundaries before any payload enters analytics or reminder pipelines. Do not cache full HTML or retain session cookies past the execution lifecycle.
  • Computation is advisory, never authoritative. A date reconstructed from the Register UI is decision-support. The controlling deadline is whatever the EPO recognizes, and every emitted value must be traceable to the exact source event, extraction method, and rule version that produced it.
  • No control-circumvention. A 403, a CAPTCHA challenge, or an IP block is a hard stop — never a signal to rotate proxies aggressively or solve the challenge. It halts automation and routes the application to a manual ingestion queue.

Prerequisites & Dependency Map

The fallback worker has a small, explicit dependency surface. Pin every item so a behavioral change is a reviewable diff rather than ambient drift.

Dependency Minimum version Role
Python 3.11 Native zoneinfo, datetime.UTC, structural pattern matching
playwright 1.44 Headless Chromium orchestration, network interception
pydantic 2.5 Extracted-payload validation and coercion
tenacity 8.2 Declarative backoff on the primary OPS path
httpx 0.27 HTTP/2 client for the OPS probe that precedes fallback
tzdata 2024.1+ IANA zone database on platforms without a system copy

Upstream inputs that must be resolved before the worker runs:

  1. A tripped circuit breaker. The fallback is never called directly; it is invoked only by the routing layer described in Step 1, which owns the decision to abandon the API.
  2. Application or publication number — normalized to EPODOC format (EP12345678) before any query.
  3. Selector map — a version-pinned mapping of DOM anchors to canonical fields, cited to the current Register UI so a layout change is a config bump, not a code change.
  4. Async task queue — fallback work runs off the primary docketing threads, deduplicated by application number and prioritized by deadline proximity, as covered in WIPO API Async Polling Patterns.
# epo_register_fallback.yaml
# Source of truth: EPO OPS + Register service docs and the live Register UI.
# https://www.epo.org/en/searching-for-patents/data/web-services/ops
# https://register.epo.org
selector_version: "2026.07.0"
base_url: "https://register.epo.org/application"
throttle_seconds: 3            # minimum gap between sequential fallback requests
nav_timeout_ms: 30000
selector_timeout_ms: 15000
selectors:                     # pinned DOM anchors; bump selector_version on any change
  events_table: "#tabBibliographic .events-table"
  title: "#biblioTitle"
  filing_date: "#biblioFilingDate"
  event_rows: ".events-table tbody tr"
circuit_breaker:
  api_retries: 3               # backoff attempts on the OPS path before tripping
  publication_lag_days: 14     # stale-status threshold that also trips the breaker
  min_success_rate: 0.85       # over 24h; below this, halt automation -> manual queue

Step-by-Step Implementation

The fallback is a deterministic pipeline anchored to a single application. Each step below is independently verifiable — run its snippet in isolation and assert the intermediate value before composing the whole.

Step 1 — Route through a deterministic circuit breaker

Browser automation is invoked only when the API path is demonstrably unhealthy. The breaker evaluates response integrity — HTTP status after bounded retries, presence of deadline-bearing fields, and publication freshness — and returns a single enum the caller acts on. This conditional routing mirrors the isolation pattern used in USPTO Patent Center Web Scraping, where API degradation is fenced off from automated parsing.

from __future__ import annotations

from datetime import date, datetime, timedelta
from enum import Enum


class Route(str, Enum):
    USE_API = "use_api"            # API healthy and complete; do not fall back
    FALLBACK = "fallback"          # trip the breaker; invoke the headless path
    MANUAL = "manual"              # neither path is safe; route to a human queue


REQUIRED_FIELDS = ("event_date", "procedure_step", "fee_status")


def decide_route(
    *,
    status_code: int,
    api_payload: dict | None,
    last_publication: date,
    today: date,
    api_retries_exhausted: bool,
    lag_threshold_days: int = 14,
) -> Route:
    """Deterministically choose the ingestion path for one application.

    A 403 is a control signal, not a data error: it never triggers the browser
    fallback, because scripting past an access control is out of bounds.
    """
    if status_code == 403:
        return Route.MANUAL
    if status_code == 429 or status_code >= 500:
        return Route.FALLBACK if api_retries_exhausted else Route.USE_API
    if api_payload is None:
        return Route.FALLBACK
    # Any missing or null deadline-bearing field trips the breaker.
    if any(api_payload.get(f) is None for f in REQUIRED_FIELDS):
        return Route.FALLBACK
    # Stale status: the register has not propagated within the freshness window.
    if today - last_publication > timedelta(days=lag_threshold_days):
        return Route.FALLBACK
    return Route.USE_API


# Verify: exhausted retries on a 503 must fall back, but a first 503 retries the API.
# assert decide_route(status_code=503, api_payload=None, last_publication=date(2026, 6, 1),
#                     today=date(2026, 6, 2), api_retries_exhausted=True) is Route.FALLBACK

The breaker must also maintain a sliding window of fallback success. If the 24-hour success rate drops below the pinned min_success_rate, halt automation entirely and route new work to the manual queue — a UI restructure that silently breaks every selector should never masquerade as thousands of individually plausible failures.

Step 2 — Execute the headless extraction deterministically

Browser automation for legal data requires explicit waits, aggressive asset filtering, and strict session isolation. The extractor blocks non-essential requests to cut latency and shrink the fingerprinting surface, anchors every wait to a rendered container rather than a fixed sleep, and always tears the context down in a finally block.

import logging
import re
from datetime import UTC, datetime
from typing import Any

from playwright.async_api import (
    TimeoutError as PlaywrightTimeout,
    async_playwright,
)

logger = logging.getLogger("epo_register_fallback")

# Playwright's glob URL matcher does not support brace expansion, so match the
# full request URL with a compiled regex.
_ASSET_RE = re.compile(r"\.(png|jpe?g|gif|css|woff2?|svg)(\?|$)", re.IGNORECASE)


async def fetch_epo_register_fallback(
    ep_number: str, cfg: dict[str, Any]
) -> dict[str, Any] | None:
    """Reconstruct register state from the EPO Register UI.

    Returns a raw dict for validation in Step 3, or None on a recoverable
    failure. Never raises past the finally block.
    """
    clean_number = ep_number.replace(".", "").replace("EP", "")
    sel = cfg["selectors"]
    target_url = (
        f"{cfg['base_url']}?number=EP{clean_number}&tab=tabBibliographic"
    )

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) LegalTechDocket/2.1",
            viewport={"width": 1280, "height": 900},
        )
        await context.route(_ASSET_RE, lambda route: route.abort())
        page = await context.new_page()
        try:
            await page.goto(
                target_url, wait_until="domcontentloaded",
                timeout=cfg["nav_timeout_ms"],
            )
            # Anchor to the procedural-events table before reading anything.
            await page.wait_for_selector(
                sel["events_table"], timeout=cfg["selector_timeout_ms"]
            )

            title = (await page.locator(sel["title"]).inner_text()).strip()
            filing_date = (await page.locator(sel["filing_date"]).inner_text()).strip()

            events: list[dict[str, str]] = []
            rows = await page.locator(sel["event_rows"]).all()
            for row in rows[:3]:  # latest three events carry docketing relevance
                cells = await row.locator("td").all_text_contents()
                if len(cells) >= 3:
                    events.append({
                        "event_code": cells[0].strip(),
                        "event_date": cells[1].strip(),
                        "description": cells[2].strip(),
                    })

            return {
                "ep_number": f"EP{clean_number}",
                "title": title,
                "filing_date": filing_date,
                "latest_events": events,
                "source": "epo_register_fallback",
                "scraped_at": datetime.now(UTC).isoformat(),
            }
        except PlaywrightTimeout:
            logger.warning("Timeout fetching EPO Register for EP%s", clean_number)
            return None
        except Exception as exc:  # noqa: BLE001 — logged, never swallowed silently
            logger.error("Headless fallback failed for EP%s: %s", clean_number, exc)
            return None
        finally:
            await context.close()
            await browser.close()

Step 3 — Normalize and validate against a strict schema

The Register UI relies on dynamic rendering and nested tables that restructure during maintenance cycles, so hardcoded XPath is brittle; anchor to pinned parent containers and validate row shape before extraction. Immediately after extraction, coerce the raw dict through the Pydantic model in the next section. A validation failure is not a parsing hint — do not retry with heuristic date parsing or string surgery. Log the raw DOM snapshot, flag the record for review, and defer. Field-level error classification here follows the taxonomy defined in Schema Validation & Error Categorization.

Mapping the Register UI DOM to the canonical record The EPO Register web page, shown as a browser panel, exposes the application number in its URL, a biblio filing-date field, and a procedural events table. Arrows map each of these DOM anchors onto the corresponding CanonicalRegisterRecord fields: the number to ep_number, the filing-date field to filing_date, and the events-table rows to the latest_events list. The record passes through the Pydantic validation gate, which routes valid records to the append-only ledger and invalid records to quarantine for paralegal review. register.epo.org/application?number=… url ?number= EP 1 234 567 #biblioFilingDate 2020-01-15 .events-table code date description B1 2026-03-11 grant published R71 2025-12-02 intent to grant GFPE 2025-09-18 fee paid CanonicalRegisterRecord ep_number: str pattern ^EP\d{7,}$ filing_date: date latest_events[]: CanonicalRegisterEvent source: Literal scraped_at: datetime audit_hash bound to selector_version Pydantic gate min_length not-future EPODOC pattern Append-only ledger immutable audit Quarantine paralegal review + DOM snapshot number filing events valid invalid

API Contract & Schema

Docketing platforms consume the fallback through a stateless, idempotent boundary identical in shape to the OPS path, so a downstream consumer cannot tell — and must not care — which vector produced a record beyond the source provenance field. Strict Pydantic v2 validation rejects malformed extractions before any arithmetic, and an idempotency key deduplicates events across overlapping polls or a retried fallback.

from datetime import date, datetime
from hashlib import sha256
from typing import Literal

from pydantic import BaseModel, Field, field_validator


class CanonicalRegisterEvent(BaseModel):
    event_code: str = Field(min_length=1, max_length=16)
    event_date: date
    description: str = Field(min_length=5)


class CanonicalRegisterRecord(BaseModel):
    ep_number: str = Field(pattern=r"^EP\d{7,}$")
    filing_date: date
    latest_events: list[CanonicalRegisterEvent] = Field(min_length=1)
    source: Literal["epo_register_fallback", "ops_api"]
    scraped_at: datetime

    @field_validator("latest_events")
    @classmethod
    def dates_not_in_future(
        cls, v: list[CanonicalRegisterEvent]
    ) -> list[CanonicalRegisterEvent]:
        # A register event dated in the future means a misparsed cell -> quarantine.
        for ev in v:
            if ev.event_date > datetime.now().date():
                raise ValueError(f"future-dated event: {ev.event_code}")
        return v

    @property
    def idempotency_key(self) -> str:
        latest = max(self.latest_events, key=lambda e: e.event_date)
        # Same latest event on two polls collapses to one docket entry.
        return f"{self.ep_number}:{latest.event_code}:{latest.event_date.isoformat()}"

    def audit_hash(self, selector_version: str) -> str:
        latest = max(self.latest_events, key=lambda e: e.event_date)
        payload = (
            f"{self.ep_number}|{latest.event_code}|{latest.event_date.isoformat()}"
            f"|{self.source}|{selector_version}"
        )
        return sha256(payload.encode()).hexdigest()

The UI itself carries no ETag, so unlike the OPS path the fallback cannot short-circuit on 304 Not Modified. The idempotency_key is what prevents a reconstructed event from creating a duplicate docket entry: a caller replaying the same key receives the identical stored record without re-triggering reminder webhooks, and the audit_hash — bound to the selector_version — records exactly which extraction contract produced the value.

Edge Cases & Failure Modes

The happy path is trivial; the value of the fallback is in the failures it refuses to hide. A tiered error taxonomy keeps recoverable faults separate from ones that must halt automation.

Error class HTTP / Playwright signal Recovery action
Transient 429, 502, TimeoutError Exponential backoff (2m → 8m → 32m), max 3 attempts
Structural ElementNotFound, ValidationError Quarantine record, alert ops, capture DOM snapshot
Compliance 403, CAPTCHA, IP block Halt automation, route to manual ingestion — never solve or evade

Beyond the table, four failure modes recur in production:

  • Silent UI restructure. A maintenance release renames .events-table and every selector misses at once. The pinned selector_version plus the sliding success-rate gate in Step 1 turns thousands of individually plausible failures into one loud alarm.
  • Publication lag mistaken for absence. An event that OPS has not yet published may also be missing from the UI. The fallback confirms availability, not existence — a still-empty events table after fallback means “unknown,” and the deadline stays flagged, not cleared.
  • Ambiguous date locale. The UI can render 04/05/2026 where day/month order is ambiguous. Never guess: reject any cell that does not match an unambiguous YYYY-MM-DD pattern and quarantine it, exactly as the dates_not_in_future validator forces future-dated misparses to surface.
  • Overlapping requests for one application. Two fallback tasks racing on the same EP number waste quota and can interleave writes. Deduplicate by application number in the async queue before dispatch, and correlate every attempt with an ID for full pipeline visibility.

Verification & Regression Testing

Anchor the breaker and validator to known inputs and run the suite on every selector-file change. These assertions are the contract:

from datetime import date

import pytest
from pydantic import ValidationError


def test_403_never_falls_back_to_browser():
    # A control signal must route to a human, not the headless path.
    assert decide_route(
        status_code=403, api_payload={"event_date": "x"},
        last_publication=date(2026, 6, 1), today=date(2026, 6, 2),
        api_retries_exhausted=True,
    ) is Route.MANUAL


def test_missing_deadline_field_trips_breaker():
    payload = {"event_date": None, "procedure_step": "R71(3)", "fee_status": "PAID"}
    assert decide_route(
        status_code=200, api_payload=payload,
        last_publication=date(2026, 6, 1), today=date(2026, 6, 2),
        api_retries_exhausted=False,
    ) is Route.FALLBACK


def test_future_dated_event_is_quarantined():
    with pytest.raises(ValidationError):
        CanonicalRegisterRecord(
            ep_number="EP1234567", filing_date=date(2020, 1, 1),
            latest_events=[{
                "event_code": "R71", "event_date": "2999-01-01",
                "description": "communication of intention to grant",
            }],
            source="epo_register_fallback", scraped_at="2026-07-02T00:00:00Z",
        )


def test_idempotency_key_collapses_repeat_polls():
    rec = CanonicalRegisterRecord(
        ep_number="EP1234567", filing_date=date(2020, 1, 1),
        latest_events=[{
            "event_code": "B1", "event_date": "2026-03-11",
            "description": "grant of the patent published",
        }],
        source="epo_register_fallback", scraped_at="2026-07-02T00:00:00Z",
    )
    assert rec.idempotency_key == "EP1234567:B1:2026-03-11"

The 403 case proves control signals never reach the browser, the missing-field case proves the breaker trips on incomplete API data, the future-date case proves the validator fails closed on a misparse, and the idempotency case pins the deduplication key that protects downstream reminders.

Operational Action Summary

Operational Action: Gate the fallback behind the circuit breaker only — never call fetch_epo_register_fallback directly. Treat a 403/CAPTCHA as a hard stop to a manual queue, and auto-disable automation when the 24-hour fallback success rate drops below the pinned min_success_rate.

Operational Action: Treat epo_register_fallback.yaml as code. Pin selector_version, review selector changes against the live UI, and enforce the 3-second throttle and one-in-flight-per-application rule in the async queue so retrieval never degrades register.epo.org.

Operational Action: Log every fallback invocation to append-only storage — correlation ID, trigger reason, duration, extracted field count, validation status, and the audit_hash — with retention aligned to your firm’s legal-practice standards, and route every quarantined record to a paralegal dashboard with side-by-side DOM snapshot and API-response diff before any deadline is updated.

When a fallback surfaces attached procedural documents or gazette correspondence, hand them to a structured extraction pipeline rather than parsing raw HTML; the Automating EPO Bulletin PDF Extraction guide defines that document-ingestion contract.

Frequently Asked Questions

When should a docketing system fall back from the EPO OPS API to a headless browser?
Only after the API path is demonstrably unhealthy: an HTTP 429 or 5xx that survives three bounded backoff retries, a deadline-bearing field (event_date, procedure_step, fee_status) resolving to null, or publication lag exceeding the pinned freshness window. An HTTP 403 is never a fallback trigger — it is a control signal that routes to manual ingestion.
Is scraping the EPO Register web UI permitted?
Only as a last-resort fallback, and only within limits. The OPS REST API is the sanctioned machine-readable channel; the Register UI must be accessed in compliance with its robots.txt, with a minimum 3-second delay between requests, one request in flight per application, and no attempt to circumvent access controls or solve CAPTCHAs. Full HTML must not be cached and session cookies must not outlive the execution.
How do I stop a fallback from creating duplicate docket entries?
Build an idempotency key from the application number plus the latest event code and date (EP1234567:B1:2026-03-11). A caller replaying the same key receives the identical stored record without re-firing reminder webhooks, so overlapping polls or a retried fallback collapse to a single docket entry.
What happens when the EPO Register UI layout changes and every selector breaks?
Selectors are pinned to a selector_version in the YAML config, and the breaker tracks a 24-hour fallback success rate. A UI restructure drives that rate below the configured floor, which halts automation and routes new work to the manual queue — converting thousands of individually plausible failures into one loud alarm rather than silent data loss.
Should reconstructed Register dates be treated as authoritative deadlines?
No. Any date reconstructed from the UI is decision-support only. The controlling deadline is whatever the EPO recognizes, and every emitted value must be traceable to the source event, extraction method, and selector_version via its audit hash. Records that fail validation are quarantined for paralegal review, never used to update a deadline automatically.

← Up to Patent Office Portal Sync & Data Ingestion