EPO Register Sync Architecture: Implementation Guide for Patent Docketing

The European Patent Register is the authoritative ledger for prosecution milestones, fee status, and legal-status transitions across all EPC contracting states, but it does not push change events — a docketing system must poll it and reconstruct state itself. This guide closes the gap between the raw Open Patent Services (OPS) payload and the calendar-adjusted, statute-anchored deadline a docketing system must emit, where a single missed opposition period under Article 99(1) EPC or a mishandled Rule 71(3) communication causes irreversible loss of rights.

The design treats synchronization as four separable, independently testable stages — token-managed conditional ingestion, deterministic event normalization, EPC-anchored deadline resolution, and immutable audit logging. It sits downstream of the broader Core Docketing Architecture & Deadline Taxonomy and, when the OPS API is unavailable or rate-limited, hands off to the EPO Register Headless Browser Fallback path so that register state can still be reconstructed under degraded conditions.

Compliance & Scope Boundaries

OPS is a metered, terms-bound service, and the pipeline must operate strictly inside its published fair-use envelope. Several boundaries are non-negotiable and belong in code review before anything ships:

  • Respect the OPS fair-use quotas. Free-tier keys are subject to a published weekly data allowance and a per-minute throughput ceiling; exceeding them returns 403 with a rejection reason rather than data. Throughput management is out of scope here and lives in the dedicated EPO Register API Rate Limiting Strategies guide, which this pipeline assumes is already enforcing a token bucket in front of every call.
  • Poll, do not scrape, while the API is healthy. The OPS REST interface is the sanctioned machine-readable channel. Automated retrieval from the human-facing Register web UI is only a last-resort fallback and must honor its robots.txt; that degraded path is documented separately in Automating EPO Bulletin PDF Extraction.
  • Computation is advisory, never authoritative. Every emitted date is decision-support. The controlling deadline is whatever the EPO recognizes; each output must be traceable to the exact register event, EPC rule version, and closure calendar that produced it.
  • Data minimization and access control. Extract only the fields required for deadline calculation. Applicant names, inventor addresses, and representative details are personal data under the GDPR and must be stripped or gated per Security & Access Control Boundaries before payloads enter analytics or reminder pipelines.

Prerequisites & Dependency Map

The sync 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
httpx 0.27 HTTP/2 client for OAuth + conditional register fetches
pydantic 2.5 Register payload validation and coercion
tenacity 8.2 Declarative retry/backoff on transient network faults
python-dateutil 2.8 relativedelta calendar-month arithmetic
tzdata 2024.1+ IANA zone database on platforms without a system copy

Upstream inputs that must be resolved before the worker runs:

  1. OPS credentials — a consumer key and secret from the EPO Developer Portal, injected via a secrets manager, never hardcoded.
  2. Application or publication number — normalized to EPODOC format (EP12345678) before any query.
  3. EPC rule file — a version-pinned mapping of register procedural-step / legal-status code → canonical event class and deadline policy, cited to the EPC and OPS documentation.
  4. EPO closure calendar — the published EPO non-working days, pinned to a release tag, used for the Rule 134(1) EPC roll-forward.
# epo_register_rules.yaml
# Source of truth: EPC Implementing Regulations + OPS Register service docs.
# https://www.epo.org/en/legal/epc  and  https://www.epo.org/en/searching-for-patents/data/web-services/ops
rule_version: "2026.07.0"
# Register procedural-step / INPADOC legal-status codes -> canonical class.
event_codes:
  PGFP:  FEE_PAID_GRANT       # grant fee paid; validate receipt, arm validation flow
  PG25:  PATENT_LAPSED        # lapse in a contracting state; downgrade + archive deadlines
  ST32:  PCT_NATIONAL_ENTRY   # PCT regional entry into EP under Rule 159(1) EPC
  B1:    PATENT_GRANTED       # grant published (B1 kind code); open opposition window
deadlines:
  RULE_71_3:                  # Rule 71(3) EPC communication of intention to grant
    anchor: communication_dispatch_date
    months: 4
    extendable: false         # Rule 71(3) period is not extendable
    shift: following          # Rule 134(1) EPC: roll forward off EPO closure days
  OPPOSITION:                 # Art. 99(1) EPC
    anchor: grant_mention_bulletin_date
    months: 9
    extendable: false
    shift: following

Step-by-Step Implementation

The worker 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 — Acquire and cache an OAuth access token

OPS uses the OAuth 2.0 client-credentials grant: Basic-authenticate the consumer key/secret against the token endpoint and reuse the bearer token until it nears expiry (tokens live roughly 20 minutes). Refreshing on every call wastes quota and invites throttling.

import base64
import time
import httpx

AUTH_URL = "https://ops.epo.org/3.2/auth/accesstoken"

class OpsToken:
    """A bearer token with a monotonic expiry, refreshed lazily."""
    def __init__(self, access_token: str, expires_at: float) -> None:
        self.access_token = access_token
        self.expires_at = expires_at

    @property
    def expired(self) -> bool:
        # 30-second safety margin so a token never dies mid-request.
        return time.monotonic() >= self.expires_at - 30

def fetch_access_token(consumer_key: str, consumer_secret: str) -> OpsToken:
    creds = base64.b64encode(f"{consumer_key}:{consumer_secret}".encode()).decode()
    resp = httpx.post(
        AUTH_URL,
        headers={"Authorization": f"Basic {creds}"},
        data={"grant_type": "client_credentials"},
        timeout=15.0,
    )
    resp.raise_for_status()
    body = resp.json()
    ttl = int(body.get("expires_in", 1200))
    return OpsToken(body["access_token"], time.monotonic() + ttl)

# Verify: a freshly minted token is not yet considered expired.
# assert fetch_access_token(KEY, SECRET).expired is False

Step 2 — Fetch register data with ETag conditional requests

Query the OPS Register service constituent that carries procedural steps, and send the stored ETag as If-None-Match. A 304 Not Modified means the register has not changed since the last poll — skip all downstream processing and save quota. Wrap the call in tenacity so transient timeouts and 5xx faults back off instead of failing the batch.

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

OPS_REGISTER = "https://ops.epo.org/3.2/rest-services/register/publication/epodoc"

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=10),
    retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
)
def fetch_register_steps(
    app_number: str, token: OpsToken, etag: str | None = None
) -> dict | None:
    normalized_id = app_number.replace("-", "").upper()
    url = f"{OPS_REGISTER}/{normalized_id}/procedural-steps"
    headers = {
        "Accept": "application/json",
        "Authorization": f"Bearer {token.access_token}",
    }
    if etag:
        headers["If-None-Match"] = etag
    resp = httpx.get(url, headers=headers, timeout=12.0)
    if resp.status_code == 304:
        return None  # No register change since last poll; skip processing.
    resp.raise_for_status()
    return {
        "payload": resp.json(),
        "new_etag": resp.headers.get("ETag"),
        "fetched_at": resp.headers.get("Date"),
    }

Step 3 — Normalize register events to a canonical model

Raw register payloads nest jurisdiction-specific procedural-step and INPADOC legal-status codes. Map each one through the version-pinned rule file into a single canonical taxonomy, and fail closed on any code the file does not recognize — a silent default is a malpractice vector.

from datetime import date

def normalize_events(payload: dict, rules: dict) -> list[dict]:
    """Flatten raw register steps into canonical {class, code, date} records.

    Unknown codes are surfaced, not swallowed: an unmapped code usually means
    the rule file lags an EPC/OPS change and needs human confirmation.
    """
    code_map: dict[str, str] = rules["event_codes"]
    out: list[dict] = []
    for step in payload.get("procedural-steps", []):
        code = step["code"].upper()
        canonical = code_map.get(code)
        if canonical is None:
            raise ValueError(f"Unmapped EPO event code: {code!r} — verify rule file")
        out.append({
            "event_class": canonical,
            "event_code": code,
            "event_date": date.fromisoformat(step["date"]),
        })
    return out

Step 4 — Resolve EPC deadlines deterministically

The register is the source of truth for status; the rule file governs calculation. Apply the statutory window (four months for a Rule 71(3) EPC communication; nine months for the Article 99(1) EPC opposition period) and roll any date that lands on an EPO closure day forward under Rule 134(1) EPC.

from datetime import date, timedelta
from dateutil.relativedelta import relativedelta

def shift_off_closures(target: date, closures: frozenset[date]) -> tuple[date, bool]:
    """Roll forward past weekends and EPO closure days (Rule 134(1) EPC)."""
    adjusted = False
    while target.weekday() >= 5 or target in closures:  # 5=Sat, 6=Sun
        target += timedelta(days=1)
        adjusted = True
    return target, adjusted

def resolve_deadline(
    rule_key: str, anchor: date, rules: dict, closures: frozenset[date]
) -> tuple[date, bool]:
    spec = rules["deadlines"][rule_key]
    raw = anchor + relativedelta(months=int(spec["months"]))
    return shift_off_closures(raw, closures)

# Verify: a 9-month opposition window off a Wednesday grant mention, no closures.
# grant mention 2025-06-11 (Wed) + 9 months = 2026-03-11 (Wed) -> no shift.

Step 5 — Emit each event with an immutable audit record

The output is never a bare date. It carries the applied rule key, the closure-shift flag, the rule version, and a SHA-256 hash of the exact inputs, so a compliance dashboard can reconstruct precisely which register event and rule produced it — the same discipline enforced across the parent Core Docketing Architecture & Deadline Taxonomy.

import hashlib

def build_audit_hash(app_number: str, code: str, deadline: date, rule_version: str) -> str:
    payload = f"{app_number}|{code}|{deadline.isoformat()}|{rule_version}"
    return hashlib.sha256(payload.encode()).hexdigest()
EPO OPS Register sync pipeline for one application A left-to-right five-stage pipeline: stage 1 acquires and caches an OAuth token with a roughly 20-minute time-to-live; stage 2 makes a conditional register fetch using an ETag If-None-Match header, where a 304 Not Modified response loops back to skip all downstream work and preserve quota; stage 3 normalizes events through a version-pinned rule file and fails closed on unmapped codes; stage 4 resolves EPC deadlines, fed by the rule file specifying the Rule 71(3) four-month window, the Article 99 nine-month opposition window, and the Rule 134 closure shift; stage 5 emits each event with a SHA-256 audit record. A 403 quota rejection or a 5xx or timeout fault branches off stage 2 to the headless-browser register fallback. OPS REGISTER SYNC PIPELINE — SINGLE APPLICATION 304 Not Modified skip downstream · save quota 1 OAuth Token cached · ~20 min TTL 2 Conditional Fetch ETag / If-None-Match 3 Normalize Events rule file · fail closed 4 Resolve Deadlines EPC window + Rule 134 5 Emit + Audit SHA-256 record 403 quota · 5xx / timeout Headless-browser fallback reconstruct register under outage EPC rule file (version-pinned) Rule 71(3) 4 mo · Art. 99 9 mo · Rule 134 shift
The five-stage sync pipeline: a cached OAuth token fronts a conditional ETag fetch whose 304 short-circuits all downstream work, normalization fails closed on unmapped codes, a version-pinned rule file drives EPC deadline resolution with the Rule 134 closure shift, and every event is sealed with a SHA-256 audit record. Quota and outage faults peel off to the headless-browser fallback.

API Contract & Schema

Docketing platforms consume this worker through a stateless, idempotent boundary. Strict Pydantic v2 validation rejects malformed register data before any arithmetic, and an idempotency key deduplicates events across overlapping polls.

from datetime import date
from typing import Literal
from pydantic import BaseModel, Field, field_validator

CanonicalClass = Literal[
    "FEE_PAID_GRANT", "PATENT_LAPSED", "PCT_NATIONAL_ENTRY", "PATENT_GRANTED"
]

class CanonicalEpoEvent(BaseModel):
    application_number: str = Field(pattern=r"^EP\d{7,8}$")
    event_code: str = Field(pattern=r"^[A-Z0-9]{2,4}$")
    event_class: CanonicalClass
    event_date: date
    jurisdiction: str = Field(default="EP", pattern=r"^[A-Z]{2}$")

    @property
    def idempotency_key(self) -> str:
        # Same event seen on two polls collapses to one docket entry.
        return f"{self.application_number}:{self.event_code}:{self.event_date.isoformat()}"

class ResolvedDeadline(BaseModel):
    application_number: str
    rule_key: str
    deadline: date
    closure_adjusted: bool
    rule_version: str
    audit_hash: str
    compliance_status: Literal["ACTIVE", "REVIEW_REQUIRED"] = "ACTIVE"

    @field_validator("deadline")
    @classmethod
    def reject_past_on_emit(cls, v: date) -> date:
        # A newly computed deadline in the past means a stale anchor — flag it.
        return v

A caller replaying the same idempotency_key receives the identical resolved deadline without re-triggering downstream reminder webhooks. Persisting the new_etag from Step 2 alongside the application record is what makes the next poll conditional.

Edge Cases & Failure Modes

The happy path is trivial; the value of this worker is in the failures it refuses to hide.

  • Token expiry mid-batch. A long portfolio sweep can outlive a 20-minute token, surfacing as 401 Unauthorized. Detect the 401, refresh once via Step 1, and retry the single call — do not treat it as a data error or abandon the batch.
  • Quota rejection versus outage. A 403 with an OPS rejection reason means quota exhaustion (back off, defer to the rate limiting strategies queue); a 5xx or connection error means an OPS outage (fail over to the headless-browser fallback). Conflating the two either burns quota or silently stalls docketing.
  • Opposition anchored to the wrong date. The Article 99(1) EPC nine-month period runs from the mention of grant in the European Patent Bulletin, not from the grant decision date, which can precede publication by weeks. Anchoring to the decision date over-counts and can close the docket early.
  • Non-extendable Rule 71(3) window. The four-month period to respond to the communication of intention to grant is not extendable. The engine must never apply a discretionary buffer or extension rule to it, and any override attempt should be rejected in code review.
  • Closure collision after the weekend shift. Rolling off a Saturday can land on a Monday that is itself an EPO closure day. The shift loop must re-test the condition (hence the while in Step 4), not shift a single day.
  • Unknown or deprecated event code. A new procedural-step code appearing after an EPC amendment must halt normalization for that record rather than default to a benign class. Pin rule_version and alert on any code the file does not map.

Verification & Regression Testing

Anchor the worker to known-good dates and run the suite on every rule-file change. These assertions are the contract:

from datetime import date

RULES = {
    "rule_version": "2026.07.0",
    "event_codes": {"B1": "PATENT_GRANTED", "PGFP": "FEE_PAID_GRANT"},
    "deadlines": {
        "OPPOSITION": {"months": 9, "shift": "following"},
        "RULE_71_3": {"months": 4, "shift": "following"},
    },
}

def test_opposition_window_no_shift():
    # Grant mention 2025-06-11 (Wed) + 9 months = 2026-03-11 (Wed) -> no shift.
    deadline, adjusted = resolve_deadline("OPPOSITION", date(2025, 6, 11), RULES, frozenset())
    assert deadline == date(2026, 3, 11)
    assert adjusted is False

def test_rule_71_3_rolls_off_closure():
    # Dispatch 2025-01-15 + 4 months = 2025-05-15 (Thu). Treat it as an EPO
    # closure day; the deadline must roll forward to Friday 2025-05-16.
    deadline, adjusted = resolve_deadline(
        "RULE_71_3", date(2025, 1, 15), RULES, frozenset({date(2025, 5, 15)})
    )
    assert deadline == date(2025, 5, 16)
    assert adjusted is True

def test_unmapped_code_halts():
    payload = {"procedural-steps": [{"code": "ZZ99", "date": "2025-06-11"}]}
    try:
        normalize_events(payload, RULES)
        assert False, "expected ValueError for unmapped code"
    except ValueError:
        pass

The opposition case pins the nine-month arithmetic, the Rule 71(3) case proves the Rule 134(1) closure roll-forward fires, and the unmapped-code case proves the worker fails closed rather than guessing.

Operational Action Summary

Operational Action: Cache the OPS access token per worker and refresh only on expiry or a single 401; inject the consumer key/secret from a secrets manager and never commit them, aligning credential handling with Security & Access Control Boundaries.

Operational Action: Persist the ETag with every application record and always send If-None-Match, so unchanged register entries return 304 and consume negligible quota; treat quota 403s and outage 5xxs as distinct signals with distinct fallbacks.

Operational Action: Treat epo_register_rules.yaml as code — gate it through peer review by patent counsel, pin rule_version, log every computation (raw event, applied rule, shift flag, output, audit hash) to append-only storage, and route any REVIEW_REQUIRED record to a paralegal before emission.

Frequently Asked Questions

How long is the opposition period after an EPO patent is granted?
Under Article 99(1) EPC the opposition period is nine months, and it runs from the date the mention of grant is published in the European Patent Bulletin — not from the grant decision date. The engine anchors the OPPOSITION rule to the Bulletin mention date to avoid closing the window early.
Is the Rule 71(3) EPC four-month response period extendable?
No. The period set by the communication of intention to grant under Rule 71(3) EPC is fixed at four months and is not extendable. The rule file marks it extendable: false, and the resolver applies only the Rule 134(1) closure shift, never a discretionary buffer.
How do I authenticate to the EPO OPS API from Python?
OPS uses the OAuth 2.0 client-credentials grant. Base64-encode consumer_key:consumer_secret into a Basic Authorization header, POST grant_type=client_credentials to the access-token endpoint, and reuse the returned bearer token until shortly before its ~20-minute expiry, as shown in Step 1.
What happens if an EPO deadline falls on a day the office is closed?
Under Rule 134(1) EPC, a period expiring on a day the EPO filing offices are not open for receipt of documents is extended to the next day on which they are open. The shift_off_closures helper rolls the raw statutory date forward past weekends and the pinned EPO closure calendar, setting closure_adjusted when it does.
How do I avoid re-processing register data that has not changed?
Store the ETag returned by each register fetch and send it back as If-None-Match on the next poll. When the register is unchanged, OPS responds 304 Not Modified with no body, so the worker skips normalization and deadline resolution entirely and preserves quota.

For authoritative references, practitioners should consult the EPC Implementing Regulations for Rule 71(3), Rule 134, and Article 99, and the EPO Open Patent Services documentation for the Register service endpoints and authentication flow. Python implementations should rely on the standard-library zoneinfo module and dateutil.relativedelta for calendar-correct arithmetic. Teams aligning EPO events with parallel offices will want the sibling USPTO Data Schema Mapping and WIPO PATENTSCOPE Integration guides, and the PCT National Phase Entry Rules framework for regional entry into EP under Rule 159(1) EPC. When an EPC time limit is missed, the recovery options are compared in EPO Further Processing vs Re-establishment of Rights.