UdiWatch

API reference

Build on the UdiWatch API.

Watch public EUDAMED UDI-DI and manufacturer SRN records. Search, store a baseline, and read a diff when a watched field changes.

Not an official European Commission service. Records can be missing, late, or wrong because the upstream register is. Do not treat a response as a certificate of conformity.

Quickstart

Create an account in the app, or with the call below. The first response contains the only copy of the API key. Store it. A later login returns a session, not the key.

BASE=https://udiwatch.com

curl -sS -X POST "$BASE/v1/trial"   -H 'Content-Type: application/json'   -d '{"email":"[email protected]","password":"at-least-8"}'

export UW_KEY='uw_…'

curl -sS "$BASE/v1/search?q=16977660098955"
curl -sS -X POST "$BASE/v1/watches"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{"subject":"16977660098955"}'
curl -sS -X POST "$BASE/v1/sync"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{}'

The same routes answer on https://udiwatch.com, https://app.udiwatch.com, and https://docs.udiwatch.com. Use one host and stay on it. A POST with no body is rejected upstream with 403, so send {} when you have no fields.

Conventions

  • JSON in, JSON out, UTF-8. Send Content-Type: application/json on POST.
  • Times are ISO-8601 UTC.
  • A subject that matches CC-XX-digits (for example AT-MF-000000252) is an SRN. Anything else is a UDI-DI. Spaces are stripped. SRNs are uppercased.
  • CORS is open on /v1/* and /mcp: Access-Control-Allow-Origin: *, methods GET, POST, DELETE, OPTIONS, headers Content-Type, X-API-Key, Authorization.
  • There is no /v2. A breaking change would be a new path. The live contract is openapi.json, generated by the app. A file of that name in git can be older. Do not put a static copy in the web root.

Authentication

Two credentials exist. They are not interchangeable.

CredentialHeaderActs as
API key uw_…X-API-Key or Authorization: Bearer uw_…The account. Can manage users, keys, and the webhook.
Session us_…Authorization: Bearer us_…One user. An owner can manage the account. A member can use watches, not users, keys, or the webhook.

A bearer token that starts with us_ is a session. Any other bearer value is treated as an API key. Prefer X-API-Key for keys so the two cannot be confused.

Public reads need no credential: search, one device, one actor, OpenAPI, health, and MCP initialize / tools/list / device search tools.

GET /v1/me returns keys masked (uw_abc…wxyz). The full key is returned only by POST /v1/trial (the first key) and POST /v1/keys (each new key). The webhook signing secret is returned in full on /v1/me, because the receiver has to verify it.

Errors

Error bodies are {"error": "…"}. The message is the one you should show. Do not match on it if a status code exists.

StatusWhen
400Bad email, password shorter than 8 characters, empty subject, webhook that is not a public https URL, bad id.
401Missing or unknown key or session. Login with a wrong password. Message on login is always “Email or password is wrong.”
402The period has ended. Sync and new watches stop. Subscribe again in the app. A cancel during an open trial does not end access early.
403A member tried to manage users, keys, or the webhook.
404Unknown /v1 path, or a UDI that is not in the public rows we could read. Actor lookup returns 200 even when the SRN is thin.
409Watch cap, user cap, or API-key cap.
429Rate limit. See below.

POST /v1/trial with an email that already exists and a wrong password returns 400 and says the account exists. That endpoint is not silent about duplicates. POST /v1/login is.

Limits

LimitValue
Trial creates8 per IP per hour
Logins20 per IP per hour
Search40 per IP per minute, 8 rows returned
MCP60 calls per IP per minute
Watches25 on a trial
Users and active API keys10 each. The operator plan allows 25.
CSV import500 lines, still inside the watch cap
Audit trailNewest 100 on REST, newest 50 on MCP list_changes
Public EUDAMED cache6 hours. POST /v1/sync and adding a watch bypass it.

Our own cron rechecks active accounts about every 10 minutes and stops after 15 watches in that run. Call POST /v1/sync when you need a fresh answer now. Do not poll search in a tight loop. We cache so the upstream register is not hammered, and we may slow a key that does.

Watches and diffs

A watch is one UDI-DI or one SRN on one account. Adding it reads EUDAMED immediately and stores that snapshot. That first read does not create an event. The next successful read compares fields and writes an event only when something changed.

Compared fields: market status, status date, trade name, risk class, reference, manufacturer status, version, name, registered device count, and certificate number plus expiry plus status.

Rules that keep the feed quiet:

  • A new value that is empty does not count as a change. We do not alert because a field disappeared from one response.
  • If the new read failed, certificate diffs are skipped. A timeout is not “certificate removed”.
  • No expiry in the source means no expiry in the payload. We do not invent a date.
  • Watching the same subject again returns the existing watch. It does not reset the baseline.

Examples you can call today: UDI-DI 16977660098955 (suction Catheter, no certificate date in the public rows) and 10887714028257 (Covered CP Stent, a certificate date is published). SRN AT-MF-000000252 and CN-MF-000042120.

POST /v1/trial

No auth. Body {"email","password"}. Password at least 8 characters. Creates a 7-day trial, 25 watches, no card, and does not renew into a charge.

{
  "email": "[email protected]",
  "role": "owner",
  "plan": "trial",
  "expires_at": "2026-10-07T08:00:00+00:00",
  "watch_cap": 25,
  "watch_count": 0,
  "expired": false,
  "webhook_url": "",
  "webhook_secret": "",
  "users": [{"id": 1, "email": "[email protected]", "role": "owner", "created_at": "…"}],
  "keys": [{"id": 1, "label": "Default", "prefix": "uw_abc…wxyz", "created_at": "…", "revoked": false}],
  "api_key": "uw_…",
  "session": "us_…",
  "existing": false
}

Same email and the right password returns existing: true, a new session, and no api_key. Wrong password on an existing email is 400.

POST /v1/login

No auth. Body {"email","password"}. 200 with the same account object as /v1/me plus session. Sessions last 14 days. 401 if the password is wrong or the account has no password yet. An account created before passwords existed still works with its API key. Call POST /v1/password with that key to set the owner password.

GET /v1/me

Auth. Account, role, watch count, users, masked keys, webhook URL, the signing secret, and the last webhook delivery. An expired trial can still read this.

POST /v1/password

Auth. Body {"password"}. With a session, sets that user’s password. With an API key, sets the owner’s password.

Users

Owner or API key.

curl -sS -X POST "$BASE/v1/users"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{"email":"[email protected]","password":"another-pass"}'

curl -sS -X DELETE "$BASE/v1/users/2" -H "X-API-Key: $UW_KEY"

The new user is a member. We do not email the password. You hand it over. You cannot delete the last owner. The response is the account object, including the user list.

API keys

Owner or API key. Label is optional and trimmed to 80 characters.

curl -sS -X POST "$BASE/v1/keys"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{"label":"CI"}'

curl -sS -X DELETE "$BASE/v1/keys/2" -H "X-API-Key: $UW_KEY"

POST returns {"api_key","label"} once. DELETE sets revoked_at. Revoked keys fail closed. Watches stay on the account. You may revoke every key. Sessions keep working.

No auth. Query q. We try an exact UDI, then an exact SRN, then a trade name, then a catalogue reference.

curl -sS "$BASE/v1/search?q=16977660098955"
{
  "results": [{
    "udi": "16977660098955",
    "trade_name": "suction Catheter",
    "manufacturer_name": "JianLin Medical Import&Export Co.,LTD",
    "manufacturer_srn": "CN-MF-000042120",
    "status": "On the market",
    "status_code": "refdata.device-model-status.on-the-market",
    "risk_class": "Class I"
  }],
  "total": 1,
  "match": "udi",
  "error": null
}

match is udi, srn, name, or reference. total is the upstream count when the filter was accepted. An exact filter that claims more than 100,000 rows is discarded. That means the filter was not applied. Search rows do not include certificate dates. Fetch the device for those.

GET /v1/devices/{udi}

No auth. One public device. found: false and HTTP 404 when it is not in the rows we could read.

curl -sS "$BASE/v1/devices/10887714028257"

Besides the search fields, a found device can include status_date, manufacturer_status, basic_udi, legislation, updated, source (eudamed-public), and certs:

"certs": [{
  "number": "…",
  "expiry": "2026-11-15",
  "revision": "",
  "status": "Issued"
}]

Certificate dates come from the public certificate search on the basic UDI-DI, not reliably from the UDI-DI detail. If that search returns nothing, certs is []. HTML pages for humans are /d/{udi} on the marketing host, with MedicalDevice JSON-LD when the record exists.

GET /v1/actors/{srn}

No auth. Always 200.

{
  "srn": "AT-MF-000000252",
  "name": "Amann Girrbach AG",
  "country": "Austria",
  "manufacturer_status": "Active",
  "manufacturer_status_code": "refdata.actor-status.active",
  "device_total": 42,
  "devices": [],
  "error": null
}

devices is the first page, up to 20, not the whole catalogue. device_total is the upstream count. error is set when the register could not be read. An empty name with error means we did not find the actor. The human page is /a/{srn}.

Watches

curl -sS "$BASE/v1/watches" -H "X-API-Key: $UW_KEY"

curl -sS -X POST "$BASE/v1/watches"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{"subject":"AT-MF-000000252"}'

curl -sS -X DELETE "$BASE/v1/watches/15" -H "X-API-Key: $UW_KEY"

POST returns {"watch": {…}}. GET returns {"watches": […]}. A watch object:

{
  "id": 15,
  "kind": "udi",
  "subject": "16977660098955",
  "label": "suction Catheter",
  "status": "On the market",
  "status_code": "refdata.device-model-status.on-the-market",
  "manufacturer_name": "",
  "manufacturer_srn": "",
  "risk_class": "Class I",
  "certificate": "",
  "cert_expiry": "",
  "last_sync": "2026-09-30T08:00:00+00:00",
  "last_error": "",
  "created_at": "2026-09-30T08:00:00+00:00"
}

kind is udi or srn. certificate and cert_expiry are the first certificate in the snapshot that has that field, or empty. last_error is the last read failure. The snapshot is kept. DELETE returns {"ok": true} even if the id was not yours. It only deletes a row on your account.

POST /v1/sync

Auth. Active account. Body {}. Reads every watch on the account with no cache and returns only new diffs.

{
  "synced": 2,
  "events": [{
    "subject": "16977660098955",
    "label": "suction Catheter",
    "summary": "Market status: On the market → No longer on the market",
    "changes": [{
      "field": "Market status",
      "old": "On the market",
      "new": "No longer on the market"
    }]
  }],
  "error": null
}

An empty events array means the baseline held. error is a string when the account cannot sync (401 if signed out, 402 if expired) and the HTTP status matches. A per-watch read failure stays on that watch as last_error and does not fail the whole call.

GET /v1/events

Auth. Newest first, at most 100. An expired account can still read this.

{
  "events": [{
    "id": 4,
    "watch_id": 15,
    "subject": "16977660098955",
    "label": "suction Catheter",
    "created_at": "2026-09-30T09:00:00+00:00",
    "summary": "Market status: On the market → No longer on the market",
    "changes": [{"field": "Market status", "old": "On the market", "new": "No longer on the market"}]
  }]
}

GET /v1/expiring

Auth. Certificate dates already stored on your snapshots, from 30 days ago through 180 days ahead. This does not call EUDAMED. Sync first. No date in the snapshot means no row.

{
  "items": [{
    "subject": "10887714028257",
    "label": "Covered CP Stent",
    "certificate": "…",
    "expiry": "2026-11-15",
    "days": 46
  }]
}

POST /v1/import

Auth. Active account. Body {"csv":"…"}. One identifier per line. The first cell is used. Separators are comma, tab, or semicolon. A header cell udi, srn, udi-di, subject, or identifier is skipped.

curl -sS -X POST "$BASE/v1/import"   -H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json'   -d '{"csv":"16977660098955
AT-MF-000000252"}'

Response: {"added","skipped","errors"}. errors holds at most five strings. Hitting the watch cap stops the import. The lines already added stay.

Webhooks

Owner or API key. Body {"url":"https://example.com/udiwatch"}. An empty string clears the URL and the secret. The URL must be https. Rejected: http, localhost, names ending in .local or .internal, and a host that is itself a private, loopback, link-local, or reserved IP. A hostname is not resolved before that check, so a public name that later points at a private address is not blocked. Do not point the webhook at your own metadata service.

The first time a URL is saved, we create webhook_secret (whsec_…). It comes back on this call and on GET /v1/me. We do not POST at all when the secret is missing, and we do not follow redirects. A 3xx is a failed delivery.

POST /v1/webhook/test with body {} sends one signed event of type watch.test and an empty events array. A sync that found diffs sends watch.changed. Both use the same body:

{
  "id": "whd_…",
  "type": "watch.changed",
  "created_at": "2026-09-30T12:00:00+00:00",
  "email": "[email protected]",
  "events": [{
    "id": 1,
    "subject": "16977660098955",
    "label": "suction Catheter",
    "summary": "Market status: On the market → No longer on the market",
    "changes": [{"field": "Market status", "old": "On the market", "new": "No longer on the market"}]
  }]
}

Headers:

  • Content-Type: application/json
  • User-Agent: UdiWatch/1.0 (+https://udiwatch.com)
  • X-UdiWatch-Timestamp: unix seconds
  • X-UdiWatch-Signature: sha256= plus HMAC-SHA256 of timestamp + "." + raw body, key is the secret
  • X-UdiWatch-Event: watch.changed or watch.test
  • X-UdiWatch-Delivery: the same id as in the body

Three attempts, timeout 5 seconds, then a pause of 0.4 seconds and 0.8 seconds. A failed delivery does not fail the sync. The event is already stored. GET /v1/me then includes webhook_last_at, webhook_last_ok, and webhook_last_error. Verify the raw bytes, not a re-serialized object.

import hashlib, hmac

def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256)
    expected = "sha256=" + mac.hexdigest()
    return hmac.compare_digest(expected, signature)
const crypto = require("crypto");

function verify(secret, timestamp, rawBody, signature) {
  const mac = crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
  const expected = Buffer.from("sha256=" + mac);
  const got = Buffer.from(signature);
  return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}

Reject timestamps you consider too old for your own clock. We do not send a replay window. Email alerts are a separate path and do nothing until SMTP is configured on the server. Do not wait for mail.

MCP

Endpoint: POST https://udiwatch.com/mcp. JSON-RPC 2.0. The advertised protocol version is 2024-11-05. Server name udiwatch, version 1.0.0.

This is one HTTP request and one JSON response. It is not a stdio process, and it does not open an SSE stream or issue an MCP session id. Clients that can POST a JSON-RPC body can use it. Clients that only speak stdio, or that require a streamable-HTTP session, should use the REST API instead. The file mcp_server.py in the git repo is an old stub with two tools and no watches. Do not point a client at it.

Put the API key on the HTTP request. It is not a tool argument.

{
  "mcpServers": {
    "udiwatch": {
      "type": "http",
      "url": "https://udiwatch.com/mcp",
      "headers": {"X-API-Key": "uw_…"}
    }
  }
}

If your client has no HTTP transport, the equivalent call is curl. A body that is a single object returns one JSON-RPC object. A body that is an array is a batch of at most 20 messages. A response with no id is a notification: a single notification returns HTTP 202 and an empty JSON object. Notifications inside a batch are dropped from the response array.

Handshake

curl -sS -X POST "$BASE/mcp"   -H 'Content-Type: application/json'   -H "X-API-Key: $UW_KEY"   -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"example","version":"0"}}}'
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {"tools": {}},
    "serverInfo": {"name": "udiwatch", "version": "1.0.0"}
  }
}

Then notifications/initialized (no id) and tools/list. ping returns an empty result. An unknown method is -32601. A body that is not an object is -32600. Those are JSON-RPC errors. HTTP status is still 200.

Calling a tool

curl -sS -X POST "$BASE/mcp"   -H 'Content-Type: application/json'   -H "X-API-Key: $UW_KEY"   -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_device","arguments":{"udi":"16977660098955"}}}'

The tool payload is a JSON string inside result.content[0].text. Parse it. result.isError is true when that payload has an error field. HTTP 200 does not mean the tool succeeded.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [{"type": "text", "text": "{"found":true,"udi":"16977660098955"}"}],
    "isError": false
  }
}

Missing auth on a watch tool is isError: true and text {"error":"API key required."} or the trial-ended message. It is not an HTTP 401. HTTP 401 is reserved for the REST routes. HTTP 429 is returned before any tool runs, body {"error":"Too many MCP calls."}.

MCP tools

tools/list returns the schemas below. Arguments the schema does not mention are ignored.

ToolAuthArgumentsText payload
search_devicesNoq required, limit optional (default 10)Same object as GET /v1/search
lookup_udiNoudi requiredSame object as GET /v1/devices/{udi}. Alias of get_device.
get_deviceNoudi requiredSame as lookup_udi
get_actorNosrn requiredSame object as GET /v1/actors/{srn}
list_watchesKey or session. Expired accounts still list.none{"watches":[…]}
add_watchActive accountsubject required{"watch":{…},"error":null} or error set and watch null
sync_watchesActive accountnoneSame object as POST /v1/sync
list_changesActive accountnone{"events":[…]}, newest 50
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_watch","arguments":{"subject":"AT-MF-000000252"}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_watches","arguments":{}}}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_changes","arguments":{}}}

Unknown tool name: text {"error":"Unknown tool."} and isError: true.

OpenAPI

Machine-readable contract: /openapi.json (OpenAPI 3.0.3). Security scheme ApiKey is header X-API-Key. Sessions are not a second scheme in that file. Send them as Authorization: Bearer us_… as described above. The descriptions in the file are summaries. This page is the behaviour.

Health check, no auth: GET /health returns {"ok": true, "product": "UdiWatch"}.

Stripe

Card checkout is a 7-day trial, then the recurring price you create in Stripe. Set these on the server, nothing else in the code:

  • STRIPE_SECRET_KEY — secret key, test or live.
  • STRIPE_PRICE_ID — a recurring price, meant to be €99 a month.
  • STRIPE_WEBHOOK_SECRET — signing secret for the endpoint below.

Webhook URL: POST /v1/billing/webhook. Header Stripe-Signature. A bad signature is 400. If STRIPE_WEBHOOK_SECRET is unset the route is 503. A handler error is 500 so Stripe retries. The event id is stored only after a successful handler, and a repeat of that id returns 200 with duplicate: true. Unknown event types are acknowledged and ignored.

Handled events: checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.paused, customer.subscription.resumed, customer.subscription.trial_will_end, invoice.paid, invoice.payment_succeeded, invoice.payment_failed, invoice.payment_action_required, invoice.upcoming, and customer.deleted. Subscription events carry metadata.account_id from checkout. Invoice events refresh the subscription. paused turns watches off until Stripe resumes. customer.deleted ends access here and does not delete the UdiWatch account.

Until those three variables are set, a new account gets the same 7 days locally and the Subscribe button stays off. No card is stored here.

Cancel sets the subscription to end at period end. Access continues until expires_at. Resume clears that flag while the period is open. After it ends, POST /v1/billing/checkout starts a new subscription. The free trial is only when trial_used is false. Password reset mail needs SMTP_HOST (and SMTP_USER, SMTP_PASS, SMTP_FROM when the server requires them).

Questions about a key or a subscription: [email protected]. You can also delete the account from the app.