# EUDAMED Certificate Status & Expiry Monitor (`automa-flow/eudamed-certificate-status-expiry-monitor`) Actor

Monitor public EU MDR/IVDR certificates by certificate number, manufacturer SRN or notified body and detect status, version, portfolio and expiry changes over time. A NOT\_FOUND here never hides a EUDAMED failure, and a certificate leaving a query is never reported as a withdrawal.

- **URL**: https://apify.com/automa-flow/eudamed-certificate-status-expiry-monitor.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** Business, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$5.00 / 1,000 certificate observations

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## EUDAMED Certificate Status & Expiry Monitor

Monitor public EU MDR/IVDR certificates by certificate number, manufacturer SRN, or notified
body and detect status, version, portfolio, and expiry changes over time.

You give it a watchlist. Every run tells you **what the certificate state is now**, **what
moved since the last trustworthy check**, and **which certificates need attention** because
they were suspended, withdrawn, restricted, re-versioned, or are running out of time.

It is not another EUDAMED export. Exports leave you diffing two spreadsheets. This keeps the
last good state per certificate and gives you the events.

### What you can do with it

- **Watch a supplier's certificates.** One manufacturer SRN returns that manufacturer's whole
  public certificate portfolio, and tells you when a certificate is added to it or leaves it.
- **Watch a notified body.** Notified-body designations move. A notified-body watch shows the
  portfolio and flags certificates changing hands.
- **Run an expiry desk.** `expiry_state` and `days_to_expiry` on every row, and an
  `EXPIRING_SOON` event on the run where a certificate crosses your warning window - not on
  every run after it.
- **Catch a suspension the day it appears.** `SUSPENDED`, `WITHDRAWN`, `RESTRICTED`,
  `CANCELLED` and `REINSTATED` are first-class events, with the notified body's own
  status-change reason attached.
- **Keep scheduled compliance evidence.** Every row is an append-only observation with a
  timestamp, a stable identifier and a link to the official EUDAMED record.
- **Drive it from an agent or a script.** Deterministic JSON, a documented schema, and a
  webhook-ready `changes` view.

### How the monitoring works

```text
watchlist -> public EUDAMED JSON API -> exact-match filter -> normalized certificate state
          -> semantic fingerprint -> last-good state in the Key-Value Store
          -> status / version / expiry / portfolio events -> Dataset + webhook
```

Three things make it a monitor rather than a diff:

**A stable identity.** EUDAMED gives each certificate a ULID that survives every new version,
and a UUID that changes with each one. This Actor keys on the ULID. Keying on the UUID - the
obvious mistake - would report every routine amendment as one certificate disappearing and
another arriving.

**A semantic fingerprint.** A sha256 over the certificate's meaningful state: identity,
number, status, type, legislation, dates, version, revision, manufacturer, notified body.
Not the observation time, not the version UUID, and deliberately **not** days-to-expiry -
that moves every day, and including it would report a change on every certificate, daily,
forever.

**A refusal to guess.** A failed, rate-limited, unreadable, truncated or suspicious check
never overwrites a good baseline and never produces an event. A EUDAMED outage cannot
manufacture a suspension.

### Sample input

```json
{
  "watchItems": [
    { "type": "certificate", "value": "Z-25-052-S-IX-E", "label": "Critical supplier certificate" },
    { "type": "manufacturer_srn", "value": "DE-MF-000006413", "label": "Supplier A" },
    { "type": "notified_body_srn", "value": "0633", "label": "NB portfolio" }
  ],
  "monitorChanges": true,
  "monitorId": "primary-compliance-watch",
  "emitUnchanged": true,
  "includeDetails": true,
  "expiryWarningDays": 90,
  "maxCertificatesPerWatch": 1000
}
```

`label` is yours and comes back on every row, so alerts route themselves.

A company name is **rejected**, not guessed into an SRN - guessing would monitor a different
manufacturer and you would never know.

### Sample output

One row per certificate observation. Trimmed to the interesting fields:

```json
{
  "watch_type": "notified_body_srn",
  "watch_value": "0633",
  "watch_label": "NB portfolio",
  "check_status": "SUCCESS",
  "event_type": "STATUS_CHANGED",
  "change_types": ["STATUS_CHANGED", "SUSPENDED", "VERSION_CHANGED"],
  "changed_fields": ["certificate_status", "version_number"],
  "certificate_ulid": "01HF40MRXWKNQ07K756T8R7AES",
  "certificate_number": "Z-25-052-S-IX-E",
  "certificate_status": "SUSPENDED",
  "certificate_status_code": "refdata.certificate-status.suspended",
  "status_change_reasons": ["refdata.status-change-reason-suspended.failure-contractual-obligations"],
  "applicable_legislation": "MDR",
  "expiry_date": "2030-09-16",
  "days_to_expiry": 1471,
  "expiry_state": "VALID",
  "version_number": 2,
  "manufacturer_srn": "DE-MF-000006413",
  "notified_body_srn": "0633",
  "previous": { "certificate_status": "ISSUED", "version_number": 1 },
  "fingerprint": "…",
  "source_url": "https://ec.europa.eu/tools/eudamed/#/screen/search-certificate/…",
  "scraped_at": "2026-09-06T06:00:00Z"
}
```

`examples/sample_output.json` carries the full 53-field shape for seven different outcomes,
including a verified no-result and a source failure side by side.

### Event semantics

| Event | Fires when |
| --- | --- |
| `FIRST_OBSERVATION` | No prior complete observation exists for this certificate |
| `NO_CHANGE` | Two complete observations agree |
| `CERTIFICATE_ADDED` | A certificate appears in a portfolio that has a complete prior baseline |
| `CERTIFICATE_CHANGED` | A meaningful source field without a narrower event changed, such as a type, issue date, version-state flag or authorised representative |
| `STATUS_CHANGED` | The source status moved between two complete observations |
| `SUSPENDED` `REINSTATED` `WITHDRAWN` `RESTRICTED` `CANCELLED` | The specific decision status, alongside `STATUS_CHANGED` |
| `VERSION_CHANGED` | The version or revision moved |
| `EXPIRY_DATE_CHANGED` | The source expiry date itself changed |
| `EXPIRING_SOON` | The certificate crossed into your warning window since the last check |
| `EXPIRED` | The certificate crossed from non-expired to expired |
| `MANUFACTURER_CHANGED` | The certificate now names different manufacturer SRNs |
| `NOTIFIED_BODY_CHANGED` | The certificate now names a different notified body |
| `MISSING_FROM_QUERY` | A certificate in the last complete portfolio result is absent from this one |

Two deliberate absences. There is **no `REFUSED` event**: no such status exists in the public
data, and inventing one would be a mapping this source cannot support. There **is** a
`CANCELLED` event, because 72 live certificates use that status.

`EXPIRING_SOON` and `EXPIRED` report the *crossing*, not the standing state. The standing
state is always in `expiry_state`, so nothing is hidden - only the alert is reserved for real
movement.

Before a `MISSING_FROM_QUERY` is reported, the Actor makes one bounded check of whether
EUDAMED still publishes that certificate at all, and says which it found.

### Status and failure semantics

`check_status` is the field to branch on.

| Value | Meaning |
| --- | --- |
| `SUCCESS` | EUDAMED answered completely; this observation is trustworthy |
| `NOT_FOUND` | A complete, clean query found no match at check time |
| `PARTIAL` | The certificate was read, but the row is less complete - the optional detail request failed, or the watch exceeded its safety cap |
| `FAILED` | The check could not be completed: a timeout, a rate limit, an unreadable response, a rejected watch item, or the suspicious-drop guard |

**`NOT_FOUND` is never a source failure, and a source failure is never `NOT_FOUND`.** The two
states cannot collapse into each other anywhere in this Actor. An HTTP 200 carrying HTML - what
the Commission's edge filter and its maintenance pages return - is classified as a source
failure, never as an empty result.

Failure never propagates into your history:

- a failed or guarded check leaves the previous baseline untouched;
- a partially paginated portfolio produces **no** removal events;
- if most of a portfolio vanishes at once, the run reports a source failure rather than
  dozens of `MISSING_FROM_QUERY` events, and keeps the old baseline so the next run can
  still tell you the truth;
- one bad watch item never kills the batch - it becomes one `FAILED` row with a reason.

### Pricing

Pay per event: **$0.005** per `certificate_observation`, charged once per unique certificate
EUDAMED answered for completely.

Never charged: rejected watch items, duplicates removed before any request, the same
certificate matched by a second watch item, pagination requests, detail requests, retries,
`NOT_FOUND`, `MISSING_FROM_QUERY`, `PARTIAL`, `FAILED`, timeouts, rate limits, unreadable
responses, certificates held back by the guard, and - with `emitUnchanged` off - certificates
that did not change.

A first baseline observation **is** charged: it is current certificate data delivered to you.

With `emitUnchanged` off, a quiet day on a 1,000-certificate watchlist returns nothing and
costs nothing, which is what makes a standing daily schedule cheap to keep.

Hosted validation on Apify charged exactly 100 events for 100 complete certificate
observations. That run used $0.000315 of platform resources against $0.50 gross event revenue.
A separate 1,065-certificate source-scale run completed without retries, rate limits or source
failures and converted every observation beyond the account charge cap into a free explicit
`MAX_TOTAL_CHARGE_REACHED` diagnostic.

### API

```bash
curl -X POST "https://api.apify.com/v2/acts/<username>~eudamed-certificate-status-expiry-monitor/runs?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"watchItems":[{"type":"notified_body_srn","value":"0633","label":"NB portfolio"}],"emitUnchanged":false,"monitorId":"nb-0633-primary"}'
```

Read the changes projection after running with `emitUnchanged: false`:

```bash
curl "https://api.apify.com/v2/datasets/<datasetId>/items?view=changes&clean=true&token=$APIFY_TOKEN"
```

Dataset views are projections, not server-side filters. Views: `overview`, `changes`,
`expiry`, `diagnostics`. Run health, source counters and
billing are in the `RUN_SUMMARY` record of the run's Key-Value Store.

### Scheduling and webhooks

Save your watchlist as a Task, schedule it daily or weekly, and set `emitUnchanged` to
`false`. Each Task gets its own state history automatically. For independent API-driven
monitors, set a stable and distinct `monitorId`. Add a webhook on `ACTOR.RUN.SUCCEEDED`
pointing at your own endpoint:

```json
{
  "eventTypes": ["ACTOR.RUN.SUCCEEDED"],
  "requestUrl": "https://example.com/hooks/eudamed",
  "payloadTemplate": "{\"runId\":{{resource.id}},\"datasetId\":{{resource.defaultDatasetId}}}"
}
```

Then fetch `?view=changes&clean=true` from that dataset. A run with nothing to report sends a
webhook with an empty changes view, which is a cheap heartbeat that the watch is alive.

### Use with AI agents through Apify MCP

Expose the Actor as a typed tool in an authenticated MCP-compatible client:

```text
https://mcp.apify.com?tools=automa-flow/eudamed-certificate-status-expiry-monitor
```

Example prompt:

```text
Run automa-flow/eudamed-certificate-status-expiry-monitor for certificate
Z-25-052-S-IX-E and manufacturer DE-MF-000006413. For each certificate, give me the
current status, the expiry date and how many days are left, and say what changed since
the last check. Keep NOT_FOUND strictly separate from FAILED: NOT_FOUND means no
matching public record at this time, FAILED means we do not know. Do not infer
regulatory validity - quote the status and link the official record.
```

Because every row carries a stable `source_id`, `scraped_at` and `fingerprint`, runs from
different weeks join to each other without any extra work.

### Limitations and legal positioning

- This Actor is **independent and not affiliated with the European Commission**, EUDAMED, or
  any notified body, and is not certified or endorsed by them.
- The source is **public EUDAMED certificate information** published by the Commission.
- Results represent **the state of the source at observation time**, nothing more. EUDAMED
  can change after a run.
- Output is **not legal, regulatory, conformity-assessment, procurement, or medical advice**.
- **`NOT_FOUND` does not prove a certificate is invalid.** It means a complete query found no
  matching public record at that moment. Certificates appear only once a notified body
  registers them.
- **`MISSING_FROM_QUERY` is not withdrawal or revocation.** It is an observation about a query
  result. EUDAMED states withdrawal in the certificate status field, and this Actor reports
  that separately.
- **`UNKNOWN` expiry state is not validity.** It means no trustworthy expiry date was read.
- **Verify anything consequential against the official EUDAMED record** linked in
  `source_url` on every row.
- EUDAMED **completeness and data quality are still evolving** after the Notified Bodies and
  Certificates module became mandatory on 28 May 2026. Absence of a certificate from the
  public register is not evidence about the certificate itself.

The Actor collects organisation and regulatory identifiers only - manufacturer SRN and name,
notified-body number, certificate numbers, statuses, dates and versions. No personal contact
data is collected, and the certificate-document metadata EUDAMED exposes is deliberately not
read.

### Technical details

- Public EUDAMED JSON API over plain HTTPS. No login, no cookies, no browser, no proxy, no
  CAPTCHA solving, no access-control circumvention.
- The source serves about **60 requests per minute**. The Actor paces itself under that
  budget run-wide rather than discovering the limit by being rate-limited, and honours
  `Retry-After` when it happens anyway.
- `certificateNumber` and `actorSrn` are **substring** filters at the source - `25-052`
  matches `Z-25-052-S-IX-E`, and `DE-MF` matches hundreds of certificates. Every result is
  filtered client-side for exact equality, so a watch monitors what you asked for and nothing
  else.
- The `certificateStatus` filter that appears in third-party documentation of this API does
  **not** filter; a request carrying it returns the whole register. This Actor does not use it.
- Detail enrichment is selective: AMENDED, SUPPLEMENTED, SUSPENDED, REINSTATED, WITHDRAWN,
  RESTRICTED and CANCELLED certificates get one detail request because the live survey found
  decision or status-change data in these states. ISSUED and REISSUED stay search-only.
- Measured live on 2026-09-06 with the seven-status policy: 1,065 certificates in 454.56 s,
  using 410 requests (32 search and 378 detail), 7.2 MB peak memory, zero rate limits,
  retries or failed queries.
- State lives in one bounded Key-Value Store record per Task or `monitorId`: the last complete
  observation per certificate and the last complete membership per portfolio watch. Writes
  are verified and retried when overlapping runs race. No database or raw payloads retained.

# Actor input Schema

## `watchItems` (type: `array`):

Each entry is an object with a type, a value and an optional label of your own. type is certificate (an exact certificate number such as Z-25-052-S-IX-E), manufacturer\_srn (an EUDAMED actor SRN such as DE-MF-000006413) or notified\_body\_srn (the four-digit notified-body number such as 0633). Values are trimmed and duplicates removed before any request, so nothing is queried or charged twice. A company name is rejected rather than guessed into an SRN. Up to 500 items per run.

## `monitorChanges` (type: `boolean`):

Compare each certificate against its last complete observation and report FIRST\_OBSERVATION, CERTIFICATE\_CHANGED, STATUS\_CHANGED, SUSPENDED, REINSTATED, WITHDRAWN, RESTRICTED, CANCELLED, VERSION\_CHANGED, EXPIRY\_DATE\_CHANGED, EXPIRING\_SOON, EXPIRED, MANUFACTURER\_CHANGED, NOTIFIED\_BODY\_CHANGED, CERTIFICATE\_ADDED and MISSING\_FROM\_QUERY. A failed, guarded or truncated check never overwrites a good baseline, so a EUDAMED outage cannot manufacture a status or removal event. Turn it off for a one-off lookup that stores nothing.

## `emitUnchanged` (type: `boolean`):

When off, a run returns only certificates whose state moved, plus every diagnostic row. Each certificate is still checked and its baseline still updated, and the run summary still counts them all, but unchanged certificates are neither returned nor charged, so a standing daily watch costs only for the changes it finds.

## `includeDetails` (type: `boolean`):

Adds one request for certificates in AMENDED, SUPPLEMENTED, SUSPENDED, REINSTATED, WITHDRAWN, RESTRICTED or CANCELLED status to collect decision and version dates, status-change reasons, notified-body name and EUDAMED certificate ID. ISSUED and REISSUED certificates remain search-only. Status, expiry, version, manufacturer and notified-body number are monitored either way.

## `monitorId` (type: `string`):

Optional stable name for this monitoring history. Use a different value when separate API clients or schedules watch the same certificate set independently. Saved Tasks are isolated automatically by Task ID.

## `expiryWarningDays` (type: `integer`):

How far ahead a certificate counts as EXPIRING\_SOON. An EXPIRING\_SOON event fires on the run where a certificate crosses into this window, not on every run afterwards; the standing state is always readable in expiry\_state.

## `maxCertificatesPerWatch` (type: `integer`):

A safety cap for manufacturer and notified-body watches, not a truncation setting. A watch query matching more than this collects nothing, is reported as PARTIAL, and derives no portfolio change from a partial view, because a half-seen portfolio would look like certificates disappearing.

## Actor input object example

```json
{
  "watchItems": [
    {
      "type": "certificate",
      "value": "Z-25-052-S-IX-E",
      "label": "Critical supplier certificate"
    },
    {
      "type": "manufacturer_srn",
      "value": "DE-MF-000006413",
      "label": "Supplier A"
    },
    {
      "type": "notified_body_srn",
      "value": "0633",
      "label": "NB portfolio"
    }
  ],
  "monitorChanges": true,
  "emitUnchanged": true,
  "includeDetails": true,
  "expiryWarningDays": 90,
  "maxCertificatesPerWatch": 1000
}
```

# Actor output Schema

## `results` (type: `string`):

Every observation, including not-found, partial and failed rows.

## `changes` (type: `string`):

Status, version, expiry and portfolio movements, ready for a webhook consumer.

## `expiry` (type: `string`):

Current expiry state per certificate; filter expiry\_state client-side.

## `diagnostics` (type: `string`):

Failure-focused projection; filter check\_status client-side.

## `runSummary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "watchItems": [
        {
            "type": "certificate",
            "value": "Z-25-052-S-IX-E",
            "label": "Example MDR certificate"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/eudamed-certificate-status-expiry-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "watchItems": [{
            "type": "certificate",
            "value": "Z-25-052-S-IX-E",
            "label": "Example MDR certificate",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/eudamed-certificate-status-expiry-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "watchItems": [
    {
      "type": "certificate",
      "value": "Z-25-052-S-IX-E",
      "label": "Example MDR certificate"
    }
  ]
}' |
apify call automa-flow/eudamed-certificate-status-expiry-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/eudamed-certificate-status-expiry-monitor"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/FK0pb2vwgD6iSagsK/builds/qkc8hqpMNajheQl6x/openapi.json
