# Paris MoU Ship Detention Monitor (`titan_coder/paris-mou-ship-detention-monitor`) Actor

Watches the official Paris MoU / EMSA THETIS list of ships detained after port state control. Charges only for a real event: new detention, confirmed release, or an edited detention record. Track your fleet by IMO, flag or port state. For owners, charterers, insurers, P\&I clubs.

- **URL**: https://apify.com/titan\_coder/paris-mou-ship-detention-monitor.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 detention events

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?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Paris MoU Ship Detention Monitor

Durable monitor for the official **Paris MoU port state control detention list**, published by the
**European Maritime Safety Agency (EMSA)** through the THETIS portal that powers the inspection search on
`parismou.org`. Watch your own fleet by IMO number, a flag, a port state — or the whole list — and get
notified only when something genuinely happens: a ship is **detained**, a detention record is **edited**,
a ship is **detained again**, or a detention is **lifted**. No API key, no login, no captcha.

Built for **ship owners and managers, charterers and chartering brokers, marine insurers and P\&I clubs,
class and flag administrations, and maritime compliance teams** — anyone for whom "one of our ships (or a
ship we are about to fix) is under detention in Rotterdam" is a same-day decision, not a monthly report.

### Source

`https://portal.emsa.europa.eu/o/portlet-public/rest/detention/getCurrentDetentions.json` — the public,
anonymous REST layer of the EMSA portal behind the official Paris MoU inspection search widget. Verified
live 13.09.2026 from a plain HTTP client, no credentials of any kind:

- HTTP 200, 552,533 bytes, `{"results": [...], "total": 49, "success": true}` — **49 ships under
  detention right now**, `total` always equal to the number of records returned.
- `id` is a stable numeric identifier (e.g. `7491791432`) and is unique inside the response (49 unique ids
  across 49 records). The same id is accepted by `getInspectionDetail.json?inspectionId=` — verified.
- Every record carries `imoNumber` (exactly 7 digits, zero deviations across all 49), `shipName` and
  `detentionDate` (strictly `dd/mm/yyyy`) — those four are treated as mandatory identity and a missing
  one fails the run closed.
- `shipType`, `detentionPort`, `detentionReportingAuthority` and `flag` are delivered as attributes and
  may legitimately be null: we observed a ship (LIAM, IMO 7917874) published with `flag: null` — a ship
  with no flag state. They are still part of the billing hash, so a flag appearing later is a real,
  billable change to the record.
- `flag.performanceType` (WHITE / GREY / BLACK\_\*) is present on 41 of 49 records and legitimately absent
  on 8 — it is delivered as context and deliberately excluded from billing.
- `getInspectionDetail.json?inspectionId=<id>` returns the full inspection card: deficiency codes
  (`defectiveItem.code`, `deficiencyArea`), `groundForDetention`, `ismRelated`, ISM company (with its own
  IMO number), gross tonnage, ship age, inspection type and detention duration.
- **Volatility (measured, not estimated):** 8 new detentions in the last 7 days, 16 in 30 days; per month
  in 2026: 06 — 2, 07 — 3, 08 — 3, 09 — 13 (in 13 days). Roughly the same number of releases, since a
  released ship simply disappears from the list.
- EMSA's disclaimer (`emsa.europa.eu/disclaimer.html`) is the standard European Commission text and
  contains no restriction on commercial use.

### How it works

1. The detention list is fetched **once per run** — it is one document covering the whole Paris MoU region
   (Europe and the North Atlantic), so every watch filters that same snapshot instead of hammering the
   EMSA portal 30 times for identical bytes.
2. Each `watch` names one slice: `imo` (your fleet, 1-200 IMO numbers), `flag`, `authority` (the port
   state that reported the detention), or `all`.
3. The first check of a new watch establishes a baseline (no charge). Every later check compares the
   current slice against the durable record of what it was last time.
4. Billing is tied **only to the detention record**: IMO, ship name, ship type code, detention date, port
   LOCODE and country, reporting authority, flag code. It deliberately excludes the Paris MoU flag
   performance rating (which is revised centrally and would otherwise "change" every ship under that flag
   at once) and all human-readable descriptions. Those fields are still delivered in every row.
5. A ship that disappears from the list is **not** billed immediately. The first run that misses it emits
   an unpaid `release_candidate` row; the release is confirmed and charged only if the ship is still
   missing on the **next accepted snapshot**. A single truncated response from the source must never be
   paid for as "the ship was released".
6. A state that changes and later reverts bills every genuine transition (detained → released → detained
   again → released), never silently deduplicated against an earlier occurrence of the same state.

### Input

```json
{
  "monitorId": "my-fleet-watch",
  "watches": [
    { "watchId": "my-fleet", "scope": "imo", "imoNumbers": ["9498315", "9146053"] },
    { "watchId": "liberia-flag", "scope": "flag", "flagCode": "LR" },
    { "watchId": "uk-ports", "scope": "authority", "authorityCode": "GB" }
  ],
  "includeInspectionDetail": true,
  "notifyOn": "new_alerts",
  "webhookUrl": "https://example.com/webhook"
}
```

Add more slices later under the same `monitorId` — each watch keeps its own independent history. A
`watchId` is permanently bound to the slice it first saw; pointing the same `watchId` at a different slice
later fails the run instead of silently mixing histories.

### Output row (per change)

`watchId, watchScope, watchTarget, detentionId, changeType ("detained"|"re_detained"|
"detention_updated"|"released"|"release_candidate"), imoNumber, shipName, shipType, detentionDate,
previousDetentionDate, portCode, portName, portCountryCode, detainingAuthority, detainingAuthorityName,
flagCode, flagName, flagPerformance, deficiencyCount, detainableDeficiencyCount, groundsForDetention[],
deficiencies[], detentionType, detentionDurationDays, banned, ismCompanyName, ismCompanyImo,
grossTonnage, shipAge, inspectionDetailStatus, firstMissingAt, lastSeenAt, sourceUrl, contentHash,
monitorId, runId, discoveredAt, eventId, billed`

### Billing

Pay-per-event: `detention-event` — charged for a new detention, a re-detention, an edit to a detention
record, or a **confirmed** release. The baseline run of a watch is free, `release_candidate` rows are
free, and failed, blocked or structurally suspect checks are never charged.

### Important — read before relying on this for any chartering, insurance or compliance decision

**This is a monitor of changes to an EMSA/Paris MoU publication, not an assessment of a ship's
seaworthiness and not an insurance, class or flag determination.** A ship appearing here has been detained
by a port state after inspection, as published; a ship disappearing from the list means only that the
publication no longer lists it, which is **not** proof that the ship has physically been released or that
its deficiencies were rectified. Publication can lag the real-world event in either direction. **This
actor is an informational monitor — it is NOT legal, commercial, insurance or technical advice, and NOT a
substitute for a direct check at [parismou.org](https://parismou.org/inspection-search/inspection-search)
and with the port state authority concerned** before fixing, insuring, releasing or reporting on any
single vessel.

### Delivery guarantee: at-most-once (we would rather lose an alert than bill you twice)

Each computed change is delivered to the dataset and charged **at most once**, for as long as the
monitor's claim log exists (see the boundary below). Before any irreversible step (writing the row,
charging the event) the run takes an **atomic claim** on that exact change, using the only atomic
primitive the Apify platform offers: a request queue's unique-key insert. Exactly one run can win that
claim for a given change. The claim log is never consumed, deleted or rotated by this actor; it is a
permanent record of what was already attempted, and `coverage.claimJournalSize` reports its size each run
so you can watch it grow (the platform's counter is eventually consistent, so treat it as a lagging
estimate, not an exact count).

**Where that guarantee ends — the honest boundary.** The claim log lives in a *named* request queue
(`<prefix>-<monitorId>-claims`) in your own account. The at-most-once guarantee holds as long as that
queue keeps existing. If you — or any process holding your account credentials — delete, rename or
re-create it from the Console or API, the log starts empty and previously delivered changes can be
delivered and charged again. That is the unavoidable boundary of *any* durable storage, not a loophole in
the protocol. For the same reason, the actor's storage prefix and internal claim namespace are frozen
after release: changing either would create a fresh, empty log with exactly the same effect.

The response the platform returns for each claim is interpreted **strictly**: only a real boolean `false`
grants the right to write and charge, only a real boolean `true` denies it, and anything else — a missing
field, `null`, `0`, an empty string, a changed SDK response shape — aborts the run's delivery for that
item with `claim_protocol_error` **before** any row or charge. An answer we do not fully understand is
never read as "you may charge".

One thing we deliberately do **not** claim: the monitor's lease makes overlapping runs a fail-closed
exception rather than a fact of life, but between the moment a run verifies it still holds the lease and
the moment the dataset write or charge actually lands there is an unavoidable time gap (the platform
offers no fencing token for datasets or billing). So "a run that lost the lease can never write another
row" would be an overstatement. What actually protects your money is the claim above: the key is already
taken, so even a ghost run cannot charge for the same change twice.

The honest consequence, stated plainly: **if a run dies after taking the claim but before finishing, that
one change is lost**. It is recorded as `dataset_unknown` or `charge_unknown` and it is **not**
re-delivered on the next run — the next run moves on to that ship's next change. We deliberately chose
possible loss of one alert over the possibility of charging you twice for the same event. This is
*at-most-once* delivery, not *exactly-once*; any actor that claims exactly-once over a store without
compare-and-swap is overstating what the platform can do.

Practically this only happens if the Apify run is killed mid-delivery (platform abort, timeout, migration).
Every such case is visible: the run's `coverage` and `run_summary` report it, and `run_summary.eventsBilled`
plus Apify's own billing ledger remain the source of truth for what you actually paid for.

### Honest limits

- **The durable dataset is a delivery-attempt log, not a guaranteed mirror of the default dataset.**
  Each row is written to the durable dataset first, then mirrored to the run's default dataset before
  billing proceeds for that row. If the durable write succeeds but the default-dataset mirror write fails
  (e.g. transient Apify storage error), the item is marked `dataset_unknown`, billing for it is
  permanently blocked (fail-closed — we never charge for a row we can't confirm was delivered), and the
  run is not retried into re-creating that exact row. The durable dataset can therefore end up with a
  small number of orphan rows that were never mirrored and never billed. The **default dataset is the
  canonical log of rows successfully written to this run's output** (see its `run_summary` row) — but a
  default-dataset row does not by itself prove the row was billed: the row is written before
  `Actor.charge()` runs, so if charging then fails or comes back `charge_unknown`, the row is present but
  not confirmably paid. **`run_summary.eventsBilled` and Apify's own billing ledger are the source of
  truth for confirmed payment**, not the presence of a row in either dataset.
- **This is the *current detentions* list, not a historical archive and not the inspection stream.** It
  answers "which ships are detained right now, and what changed since my last check". It does not
  back-fill detentions that ended before your first run, and it does not report inspections that produced
  no detention. A ship's inspection card is fetched only to enrich an event that already happened.
- **A release is inferred from disappearance, and confirmed over two runs.** The source publishes no
  "released" flag: a ship that is let go simply stops appearing. We therefore require two consecutive
  accepted snapshots without the ship before charging a `released` event, and we emit an unpaid
  `release_candidate` row after the first one. The practical cost is that a release is reported one run
  late; the practical benefit is that a truncated response from the portal is never sold to you as a
  release. On the other side, a `released` event still means "no longer published", not "verified back at
  sea".
- **Truncated source responses are refused, not interpreted.** If `total` disagrees with the number of
  records returned, if the list falls below an absolute floor, if the whole list shrinks by more than 40%
  against the previous accepted snapshot, or if a large slice shrinks by more than 40%, the run reports
  `source_access_limited` for the affected watches and updates nothing — no tombstones, no billing. Small
  slices (your own fleet of one or two ships) are deliberately *not* subject to the per-slice test, since
  a fleet slice legitimately drops to zero when your ship is released; they are protected by the
  whole-list test instead.
- **Inspection detail is best effort, with one exception.** This API answers a non-existent
  `inspectionId` with HTTP 500, so "no such card" and "the portal is down" are indistinguishable — a
  failed enrichment therefore never cancels the detention event itself (`inspectionDetailStatus` tells
  you why it is missing). The exception is a card whose `id` or ship IMO does **not** match what we asked
  for: foreign deficiency codes inside a paid row are worse than no enrichment, so that fails the watch
  closed. Enrichment is also capped per run (40 cards) — beyond that, events are still delivered with
  `inspectionDetailStatus: budget_exhausted`.
- **A run that checks some watches but not others tells you so.** If one slice is checked fine and
  another is not trustworthy, the run still finishes as `SUCCEEDED`, but its reason becomes
  `partial_watch_failures: N/M`, `coverage.failedReasons` lists why, and the digest and webhook carry an
  explicit "result is INCOMPLETE" line. "No changes found" and "we could not look" are never reported as
  the same thing.
- **A flood of changes is capped per watch per run** (50), so a mass shift on the source side cannot
  drain your budget in one run; the remainder is picked up by the next run, because the durable index only
  advances for changes that were actually delivered.
- **The monitor holds a lease, so a second run of the same `monitorId` stops instead of running in
  parallel.** If a run dies, the lease is released after a grace window (not after the run's full
  timeout), so the next scheduled run takes over promptly instead of failing as "monitor busy". The lease
  is a mutex for orderly behaviour, not the thing that protects your billing — see the time-gap note in
  the delivery guarantee above.
- **Billing tracks the detention record only**, deliberately excluding the Paris MoU flag performance
  rating and all descriptive text. Those fields are still delivered in every row for context.
- We don't invent data: if the response is not a JSON object with `success: true`, if `results` is not a
  list, if `total` disagrees with the record count, if an `id` repeats, if a required column has vanished
  from *every* record (as opposed to being null in one), if `imoNumber` is not already exactly 7 digits,
  or if `detentionDate` is not `dd/mm/yyyy`, the run reports it honestly (`source_access_limited`)
  instead of guessing what it actually found.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

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

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars). Reuse the same value on every scheduled run so the actor remembers what the detention list looked like last time.

## `watches` (type: `array`):

1-30 slices of the Paris MoU detention list. Each item is {"watchId": "my-fleet", "scope": ...}. Scopes: "imo" with "imoNumbers" (1-200 seven-digit IMO numbers — your own fleet, or a counterparty's fleet before you charter it); "flag" with "flagCode" (two-letter flag code as published by Paris MoU, e.g. LR, PA, MT); "authority" with "authorityCode" (two-letter port state that reported the detention, e.g. GB, NL, ES); "all" for the entire list (about 50 ships at any time). One watchId is permanently bound to the slice it was first used with.

## `includeInspectionDetail` (type: `boolean`):

Fetch the inspection card for each changed ship and add deficiency codes, grounds for detention, ISM company, gross tonnage and ship age to the row. Best effort: if the card is unavailable the event is still delivered, with inspectionDetailStatus telling you why.

## `notifyOn` (type: `string`):

new\_alerts — post the webhook only when paid detention events were delivered; always — post it every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a digest of delivered (paid) detention events as JSON. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-fleet-watch",
  "watches": [
    {
      "watchId": "my-fleet",
      "scope": "imo",
      "imoNumbers": [
        "9498315",
        "9146053"
      ]
    }
  ],
  "includeInspectionDetail": true,
  "notifyOn": "new_alerts"
}
```

# Actor output Schema

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

Every row this run produced. Key fields: watchId, detentionId, changeType (detained|re\_detained|detention\_updated|released|release\_candidate), imoNumber, shipName, shipType, detentionDate, previousDetentionDate, portCode, portName, detainingAuthority, flagCode, flagPerformance, deficiencyCount, detainableDeficiencyCount, groundsForDetention, ismCompanyName, grossTonnage, shipAge, inspectionDetailStatus, firstMissingAt, lastSeenAt, contentHash, eventId. Informational only — not a seaworthiness, insurance or classification finding.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-watch status/reason/inScope/changesDetected, release candidates vs confirmed releases, snapshot size, enrichment requests used, records delivered and billed. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# 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 = {
    "monitorId": "my-fleet-watch",
    "watches": [
        {
            "watchId": "my-fleet",
            "scope": "imo",
            "imoNumbers": [
                "9498315",
                "9146053"
            ]
        }
    ],
    "includeInspectionDetail": true,
    "notifyOn": "new_alerts"
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/paris-mou-ship-detention-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 = {
    "monitorId": "my-fleet-watch",
    "watches": [{
            "watchId": "my-fleet",
            "scope": "imo",
            "imoNumbers": [
                "9498315",
                "9146053",
            ],
        }],
    "includeInspectionDetail": True,
    "notifyOn": "new_alerts",
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/paris-mou-ship-detention-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 '{
  "monitorId": "my-fleet-watch",
  "watches": [
    {
      "watchId": "my-fleet",
      "scope": "imo",
      "imoNumbers": [
        "9498315",
        "9146053"
      ]
    }
  ],
  "includeInspectionDetail": true,
  "notifyOn": "new_alerts"
}' |
apify call titan_coder/paris-mou-ship-detention-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/paris-mou-ship-detention-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/zkrgV0b2xCy3jr1J5/builds/gvAFv3N5ZD43WAzLY/openapi.json
