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
403with 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:
- OPS credentials — a consumer key and secret from the EPO Developer Portal, injected via a secrets manager, never hardcoded.
- Application or publication number — normalized to EPODOC format (
EP12345678) before any query. - 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.
- 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()
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 the401, 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
403with an OPS rejection reason means quota exhaustion (back off, defer to the rate limiting strategies queue); a5xxor 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
whilein 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_versionand 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?
OPPOSITION rule to the Bulletin mention date to avoid closing the window early.
Is the Rule 71(3) EPC four-month response period extendable?
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?
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?
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?
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.