# Container Tracking: MSC, Maersk Cargo Updates, Demurrage Alerts — API reference

The detail an integrator needs and a buyer does not: linked from the README's Integrations section. `/actor-deploy
--assets` hosts this file next to the images (any other repo file 404s on the Store).

## Tested combinations

Every row is an input the test suite and the review runs start — the combinations that are supported. An input
outside this table is accepted by the schema but not tested.

| Call | What runs | Input |
|---|---|---|
| Daily test / first agent call | the free demo on the sample records, nothing charged | `{}` |
| Demo, as the form opens | the same free demo: 4 sample containers + 1 typo, free time 4 days | the form as it opens |
| Live lookup, capped | the carrier Actors on your account, AI off, ≤ $0.05 | `{"sourceMode": "live", "trackingNumbers": ["MSCU0203658", "MAEU3402103", "MAGU5661754, CMA CGM", "CSQU3054383", "12345678"], "maxDiscoveryChargeUsd": 0.05, "enableAi": false}` |
| Pasted e-mail | the numbers pulled out of the text, then looked up | `{"sourceMode": "live", "trackingNumbers": ["Please track MSCU0203658 and MAEU3402103, thanks"]}` |
| Your Google Sheet | the sheet's numbers next to the typed ones | `{"sourceMode": "live", "numbersSheetUrl": "https://docs.google.com/spreadsheets/d/<id>/edit"}` |
| Demurrage alerts | `lastFreeDay` + `dd_risk` from 5 free days | `{"sourceMode": "live", "trackingNumbers": [ … ], "freeTimeDays": 5, "targetFlags": ["dd_risk"]}` |
| AI on | the same sample with the AI codes, note and extension request | the form with `enableAi` on |
| AI with your own key | the AI through your provider key | `{"enableAi": true, "llmProvider": "byok", "llmModel": "google:gemini-3.5-flash-lite", "llmApiKey": "…"}` + your records |
| Your own list | your carrier results, no lookup (bare numbers are looked up) | `{"sourceMode": "list", "itemsList": [ … ]}` (fields: README → List-mode rows) |
| A dataset you have | a dataset's items, no source run | `{"sourceMode": "dataset", "datasetId": "<id>"}` |
| A monitor | only containers that changed since the last run; smart polling | `{"trackingNumbers": [ … ], "dedupeAcrossRuns": true, "monitorStoreName": "client-a"}` |

## Run outcomes — what your integration sees

| Outcome | Run status | Dataset | `OUTPUT` | Charged? |
|---|---|---|---|---|
| Free demo | SUCCEEDED | 4 sample updates + 1 typo row | `qualified` | no |
| Success | SUCCEEDED | changed containers, then free not-tracked rows | `trackingSummary` | per update |
| Nothing changed | SUCCEEDED | empty | `stats.unchanged`, `pollSkipped` | not by us |
| Invalid input | FAILED | empty | — | no |
| Your plan cannot run the source Store Actor | SUCCEEDED — the same for the Console, the API and a schedule; check `OUTPUT.ok`; a scheduled / API run alerts your channels (`refusalAlerted`) | Console: your carriers' sample; API: empty | `ok: false`, `outcome: source_refused`, `sourceRefused: plan` | no — list-mode rows |
| Source failed (every lookup, or the sheet unreadable with nothing typed) | FAILED | empty | `outcome: source_failed`, `sourceError` | no |
| One carrier failed | SUCCEEDED | its numbers as free `lookupFailed` rows | `sourceStatus.carriers` | the others' |
| Crashed (rare) | FAILED | rows delivered before it | `outcome: error` | those rows |
| Source partial (a carrier timed out) | SUCCEEDED | updates from the rows returned | `sourceStatus.partial` | per update |
| AI unavailable | SUCCEEDED | rule-scored (demo: sample AI verdicts) | `aiError` | basic after the free tier |

`failOnSourceError: true` turns a refused or empty source into a FAILED run, so a schedule's alert fires.

## Which delivery field wins

every URL is posted once: **Send alerts to** fills each service you did not set; `webhookUrl` gets the rows, the chats and `notifyEmail` the digest.

## Flags

| Flag | Meaning |
|---|---|
| `dd_risk` | discharged, not picked up, the last free day ≤ 3 days away or past — demurrage starts |
| `eta_slipped` / `eta_earlier` | the ETA moved later / earlier by at least `etaSlipAlertDays` since the last run |
| `arrived` | reached the POD: arrived, discharged, delivered or empty returned |
| `departed` | the last milestone is a load or a vessel departure |
| `milestone_changed` | a new milestone since the last run |
| `first_seen` | the first time this container is tracked in this monitor |
| `no_eta` | the carrier shows no ETA |
| `unmapped_events` | an event no rule could code (kept as `OTHER` with its text) |
| `not_tracked` | invalid number, unknown carrier or a failed lookup (free) |

## Times and keys

- `eta`, `ata`, `milestoneTime` and every event `time` are the carrier's own clock: local time at the port when the
  value has no offset; with `Z` / `+08:00` it is that zone. `etaUtcOffset` is the carrier's offset, else the port of
  discharge's time zone by its UN/LOCODE (`GBFXT` → Europe/London); **`etaUtc` is the same ETA in UTC — compare and
  compute on it** (null only when neither the carrier nor the port's code gives the zone).
- `itemId` = the box (`<carrier>:<number>`); `shipmentKey` = the shipment the box is on (`+ :<B/L or booking>`, else
  `+ :<day of the first load>`) — a reused box on its next voyage gets a new key and `changeType: new_shipment`;
  `dedupeKey` = one update (`+ :<state hash>`).
- Smart polling (`pollPolicy: smart`): a `DELIVERED` / `EMPTY_RETURNED` box is looked up once more to confirm, then
  never again; a `BOOKED` / `IN_TRANSIT` box with its ETA more than 7 days out is looked up every 3rd run.
  `OUTPUT.pollSkipped` = `{"final": [numbers], "far": [numbers]}`.

## Free tier and the AI reserve

**Do rule-scored container updates use my free ones?** Yes — the first 25 container updates of an account are free in any tier; while the AI is on but unavailable on your plan, 5 of them (the AI reserve) are kept for your first AI-assessed updates (`OUTPUT.chargeSplit.freeKeptForAi`), and rule-only rows use the rest, then the basic price.

## Every field of a row

| Field | Description |
|---|---|
| `url`, `itemId`, `shipmentKey`, `dedupeKey`, `name` | carrier page · the box · the shipment · one update · the number |
| `trackingNumber`, `numberType`, `reference` | cleaned number, `container` / `bl` / `booking`, your reference |
| `carrier`, `carrierScac`, `routedBy`, `sourceActor`, `unverifiedCarrier` | carrier, SCAC, `prefix` / `override`, the Actor that answered, beta carrier (free here; its lookup still ≈ $0.01 on your account) |
| `status` | `BOOKED` · `IN_TRANSIT` · `ARRIVED` · `DISCHARGED` · `DELIVERED` · `EMPTY_RETURNED` · `UNKNOWN` |
| `milestone`, `milestoneDescription`, `milestoneLocation`, `milestoneTime`, `milestoneIsActual` | last event: DCSA code, words, UN/LOCODE, time, actual (null when none) |
| `pol`, `pod`, `finalDestination`, `vessel`, `voyage`, `eta`, `etaUtcOffset`, `etaUtc`, `ata`, `etaSlipDays` | ports, vessel, ETA (local time at the port), its offset and the ETA in UTC, ATA, the ETA move |
| `lastFreeDay`, `daysToLastFreeDay`, `demurrageAtRiskUsd` | discharge day + free time, days left (negative = demurrage running), USD at risk on a `dd_risk` box ((days past + 3) × `demurrageRatePerDay`); null when off |
| `changeType`, `note`, `events`, `problem`, `problemDetail` | what changed (`new_shipment`, `free_time` …), the note, all events, why not tracked |
| `flags`, `targetFlagsMatched`, `ruleScore`, `score`, `label`, `assessedBy` | findings; rule score; ranking score; act now / watch / no action |
| `fitScore`, `scoreReasons`, `summary`, `generatedText`, `recommendedNextStep`, `aiModel` | the AI's score, reasons, note, alert or extension request, next step |
| `chargedEvent`, `billedAs`, `source`, `checkedAt` | billing (`unverified-carrier` = free), provenance, check time (UTC) |

## One dataset item

The demo row of MSKU6104820 (local run 2026-10-24) — every field:

```json
{
  "url": "https://www.maersk.com/tracking/MSKU6104820",
  "itemId": "maersk:MSKU6104820",
  "dedupeKey": "maersk:MSKU6104820:38b303d007",
  "name": "MSKU6104820",
  "trackingNumber": "MSKU6104820",
  "numberType": "container",
  "reference": "PO-44120 sample",
  "carrier": "Maersk",
  "carrierScac": "MAEU",
  "routedBy": "prefix",
  "status": "IN_TRANSIT",
  "milestone": "DEPA",
  "milestoneDescription": "Vessel departed (export)",
  "milestoneLocation": "MYTPP",
  "milestoneTime": "2026-10-06T04:00:00",
  "milestoneIsActual": true,
  "pol": {
    "name": "TANJUNG PELEPAS, MY",
    "unlocode": "MYTPP"
  },
  "pod": {
    "name": "FELIXSTOWE, GB",
    "unlocode": "GBFXT"
  },
  "finalDestination": {
    "name": "FELIXSTOWE, GB",
    "unlocode": "GBFXT"
  },
  "vessel": "MAERSK SAMPLE BAY",
  "voyage": "442W",
  "eta": "2026-11-02T06:00:00",
  "etaUtcOffset": "+00:00",
  "etaUtc": "2026-11-02T06:00:00Z",
  "ata": null,
  "etaSlipDays": 3,
  "lastFreeDay": null,
  "daysToLastFreeDay": null,
  "demurrageAtRiskUsd": null,
  "changeType": "eta",
  "shipmentKey": "maersk:MSKU6104820:2026-10-05",
  "unverifiedCarrier": false,
  "note": "MSKU6104820 (Maersk) — in transit; last event: vessel departed (export) at MYTPP on 2026-10-06; ETA FELIXSTOWE, GB (GBFXT) 2026-11-02, 3 days later than at the last check.",
  "events": [
    {
      "code": "GTIN",
      "description": "Gate in (export)",
      "location": "Tanjung Pelepas, MY",
      "unlocode": "MYTPP",
      "time": "2026-10-03T08:10:00",
      "isActual": true,
      "vessel": null
    },
    {
      "code": "LOAD",
      "description": "Loaded on vessel (export)",
      "location": "Tanjung Pelepas, MY",
      "unlocode": "MYTPP",
      "time": "2026-10-05T21:40:00",
      "isActual": true,
      "vessel": "MAERSK SAMPLE BAY"
    },
    {
      "code": "DEPA",
      "description": "Vessel departed (export)",
      "location": "Tanjung Pelepas, MY",
      "unlocode": "MYTPP",
      "time": "2026-10-06T04:00:00",
      "isActual": true,
      "vessel": "MAERSK SAMPLE BAY"
    },
    {
      "code": "DISC",
      "description": "Discharged (import)",
      "location": "Felixstowe, GB",
      "unlocode": "GBFXT",
      "time": "2026-11-02T06:00:00",
      "isActual": false,
      "vessel": "MAERSK SAMPLE BAY"
    }
  ],
  "problem": null,
  "problemDetail": null,
  "sourceActor": "muhammetakkurtt/maersk-tracking-scraper",
  "flags": [
    "eta_slipped",
    "departed"
  ],
  "targetFlagsMatched": [],
  "ruleScore": 90,
  "score": 90,
  "label": "hot",
  "fitScore": null,
  "scoreReasons": [],
  "summary": "",
  "generatedText": "Update: MSKU6104820 (Maersk) — in transit; last event: vessel departed (export) at MYTPP on 2026-10-06; ETA FELIXSTOWE, GB (GBFXT) 2026-11-02, 3 days later than at the last check.",
  "recommendedNextStep": "Tell the consignee the new ETA at FELIXSTOWE, GB (GBFXT): 2026-11-02.",
  "aiModel": null,
  "assessedBy": "rules",
  "chargedEvent": "demo",
  "billedAs": "qualified-container-update-basic",
  "source": {
    "mode": "list",
    "normaliser": "maersk",
    "sample": true,
    "query": "Maersk"
  },
  "checkedAt": "2026-10-24T15:24:48+00:00"
}
```

## Which call runs what

| Input shape | `OUTPUT.mode` | Can it charge? |
|---|---|---|
| `{}` or the untouched form | `demo` | no — sample rows are `chargedEvent: demo` |
| the sample + your own `trackingNumbers` typed on the Console form | `live` | yes — per container update; the lookups on your account, capped |
| the same over the API (or `autoSwitchToLive: false`) | `demo` | no — the mode you send is the mode that runs |
| `sourceMode: "live"` + `trackingNumbers` / `numbersSheetUrl` | `live` | yes (beta-carrier rows free here) |
| `sourceMode: "list"` + your `itemsList` | `list` | yes — per container update; bare numbers are looked up |
| `sourceMode: "dataset"` + `datasetId` | `dataset` | yes — per container update |

## `OUTPUT` record keys

One line per key: type, one example, and the sub-keys of every nested record.

- `ok` — bool: `false` only when the source was refused / failed and nothing loaded
- `outcome` — str, one of `ok` · `empty` · `source_refused` · `source_failed` · `error`
- `mode` — str: `demo` · `live` · `list` · `dataset`
- `qualified` — int, e.g. `12`
- `trackingSummary` — object: `byCarrier`, `byStatus`, `byChange` (`{name: count}`), `etaSlips` int, `etaSlipAlertDays` int, `demurrageAtRisk` (`{usd, boxes}` | null), `notTracked` (`{invalidNumber|unknownCarrier|lookupFailed: count}`) | null
- `pollSkipped` — object | null: `{"final": ["MSCU…"], "far": ["MAEU…"]}` — numbers smart polling did not look up
- `numbersExtracted` — array | null: numbers pulled out of pasted text
- `numbersSheet` — object | null: `rows` int, `error` str
- `unverifiedCarrierRows` — int | null: beta-carrier rows delivered free
- `stats` — object: the funnel, `discovered`, `unchanged`, `not_tracked`, `not_tracked_<problem>`, `skipped_<reason>` counts, `ai_assessed`, `capped_by_max_qualified`
- `skips` — object | null: `{"<reason>": ["<name>", …]}` — the names each Actor-specific rule dropped
- `chargedEvents` — object: `{"<event>": <count>}`; `chargeSplit` — object: `demo`, `free`, `basic`, `ai`, `freeKeptForAi`
- `sourceStatus` — object | null: `status`, `partial`, `timeCapped`, `carriers` (`{<carrier>: {numbers, rows, status | error, schemaChanged, priceChanged: {expectedUsd, nowUsd}}}`)
- `sourceError` — str | null · `sourceRefused` — `"plan"` | null · `sampleFallback` — bool | null
- `aiCoverage` — object | null: `aiAssessed`, `ruleScored`, `fallbackReasons`, `sampleVerdicts`; `aiError` — str | null
- `aiTokens` — object | null: `inputPerUnit`, `outputPerUnit`, `model`
- `webhook` — object | null: `sent`, `failed`, `failedKeys`, `redelivered`, `pendingNextRun`, `signed`
- `trend` — object | null (second run on): `qualified: {prev, now}`, `hot: {prev, now}`, `etaSlips: {prev, now}`
- `history` — array | null: one `{at, qualified, hot, etaSlips}` per run of this search, oldest first
- `scheduledSizing`, `firstRunBackfill`, `seedsRolled`, `scanCapHit` — object | null: what a scheduled run resized
- `digestEmailed` — bool | null; `scheduleTaskUrl` — str | null; `reportUrl` — str | null
- `timing` — object: `sourceSecs`, `aiSecs`, `aiSecsPerUnit`, `aiBackoffSecs`, `totalSecs`
- `nextSteps` — array | null: the status steps that did not fit the 200-character status

## Supplier watch

Every lookup runs on one developer's carrier Actors (`muhammetakkurtt/*`). Before each live lookup the run reads every
called carrier Actor's current Store price: a change against the price the spend caps assume is an INFO line and
`sourceStatus.carriers.<carrier>.priceChanged`; a carrier Actor that renamed its output fields fails only its own
numbers (free `lookupFailed` rows) with `schemaChanged: true`. A daily canary and the built-in DCSA reader are dated
in the roadmap.

## White-label client reports

`clientName` and `brandName` head the `REPORT` page and the `DIGEST` ("Container updates for Acme Imports — by
Northwind Logistics"). One task per client — its numbers, both names and its own `monitorStoreName` — or an API loop:

```python
for client in [{"name": "Acme Imports", "numbers": ["MSCU0203658"]}, {"name": "Beta Foods", "numbers": ["MAEU3402103"]}]:
    client_api.actor("rich_minds/multi-carrier-tracking-ai").call(run_input={
        "sourceMode": "live", "trackingNumbers": client["numbers"], "clientName": client["name"],
        "brandName": "Northwind Logistics", "monitorStoreName": client["name"].lower().replace(" ", "-"),
        "maxDiscoveryChargeUsd": 0.5})
```
