MIK ship security alert push contract
What an external ship security alert system sends to the mik-ssa-alerts-v1 push source - fields, signing, timestamps, responses and retries.
This page is for the team that operates the system sending the ship security alert (SSA) to the MIK North Sea example. It describes exactly what the receiving side checks, so a sender can be built from it. Everything here is derived from the code that receives the delivery; the last section names the file for each rule. Nothing in it is an operating procedure: the fields follow the public outline of an SSA (SOLAS chapter XI-2 regulation 6 and the ISPS Code) and nothing more.
The receiver is the push ingest mode of the mik-ssa-alerts-v1 data source. The generic mechanics of that mode (address, ordering, deliveries set aside) are in Ingest modes; this page is the contract for this one source.
What you need from the receiving side
| Item | Value |
|---|---|
| Method and address | POST https://<push host>/api/platform/data-sources/push/<binding ID>. The binding ID is 64 lowercase hex characters, shown on the source's Push endpoint tab in Analytics, Data sources. |
| Shared secret | The value of the mik-ssa-hmac-secret connection of the installed source. It is never in the manifest and never travels in a request. Both sides hold the same string, used as UTF-8 bytes. |
| Algorithm | HMAC-SHA256. |
| Delivery content | JSON, UTF-8, at most 1 MiB and 1,000 alerts per delivery. |
One delivery
One request carries one JSON object whose alerts array holds the alerts (normally one). Send an array: an alerts value that is a single object instead of an array is also read, as one alert, but that is not part of the contract. Three headers carry the identity, the signing time and the signature:
| Header | Content |
|---|---|
x-ssa-alert-id | The delivery ID: a string of 1 to 512 characters, unique per delivery (a UUID works). It is the idempotency key, not the alert's identity. |
x-ssa-signature-timestamp | The signing time in Unix seconds, written as a decimal integer of 1 to 12 digits with no leading zeros and nothing else. The receiver reads it as a number and signs that number, so 01791936000 is signed as 1791936000 and a digest computed over the zero-padded text is refused. |
x-ssa-signature | sha256=<hex> or the bare hex digest, 64 hex characters, either case. |
Header names are matched case-insensitively, like all HTTP header names. Send Content-Type: application/json.
{
"alerts": [
{
"alertId": "3f0c7c2e-8d1a-4b6e-9a52-7e1d2c4b8f10",
"mmsi": "000000002",
"imo": "9000002",
"shipName": "Demo Aurora Tide",
"callsign": null,
"latitude": 51.62,
"longitude": 2.71,
"alertedAt": "2026-10-14T00:00:00.000Z",
"status": "active",
"provenance": "demo sender"
}
]
}Fields
Each alert is an object with these fields. Unknown fields are ignored. Types are checked per alert after the delivery is accepted (see Responses).
| Field | Type | Required | Unit and format | Example |
|---|---|---|---|---|
alertId | string | yes | The alert's identity and the table's primary key. Stable for the life of the alert: the cancel repeats it. A UUID is recommended. | 3f0c7c2e-8d1a-4b6e-9a52-7e1d2c4b8f10 |
mmsi | string | yes | Maritime Mobile Service Identity, nine digits, sent as a string: a JSON number is refused, and a number would lose the leading zeros anyway. | "000000002" |
imo | string or null | no | IMO number. May be null or absent. | "9000002" |
shipName | string | yes | The ship's name. | "Demo Aurora Tide" |
callsign | string or null | no | Radio call sign. May be null or absent. | null |
latitude | number | yes | Decimal degrees, north positive. A JSON number: a string is refused. | 51.62 |
longitude | number | yes | Decimal degrees, east positive. A JSON number: a string is refused. | 2.71 |
alertedAt | string | yes | RFC 3339 instant with a zone: ...Z or ...+hh:mm. A value without a zone is refused. Stored in UTC. | "2026-10-14T00:00:00.000Z" |
status | string | yes | "active" for an alert, "cancelled" for a cancel. See below. | "active" |
provenance | string | yes | Free text naming where the alert came from. | "demo sender" |
The receiver does not check a coordinate's range or datum: send WGS 84, and keep latitude within -90 to 90 and longitude within -180 to 180 yourself. An imo or callsign that is absent is stored as null.
Alert and cancel
There is one message shape and one endpoint. The two messages differ only by status:
- Alert:
statusis"active". A newalertIdcreates a row. - Cancel: the same body with the same
alertIdandstatusset to"cancelled", sent as a new delivery (new delivery ID). The source writes withupsertonalertId, so the cancel replaces the alert's row. Repeat every required field: the row is replaced as a whole, andalertedAtshould keep the alert's original value. The comparison is exact and lower case; any other value is stored as given and is not a cancel.
The watch that raises a detection from alerts leaves out every alert whose status is "cancelled" when it reads, so a cancelled alert raises no detection and does not show in the app. A cancel does not retract a detection or case that the active alert already opened: the watch no longer reads that row, so it takes no action on it. Whether a cancel should retract is not decided by this contract.
Timestamps
x-ssa-signature-timestampis Unix seconds in UTC (the same instant everywhere on Earth; there is no time zone to choose).- The receiver accepts a delivery whose signing time is within 300 seconds of its own clock, in either direction, edges included. Outside that, the answer is
401 push_signature_staleand nothing is read from the body. - Keep the sender's clock synchronised (NTP). A retry gets a fresh timestamp and the same delivery ID; the timestamp is not part of a delivery's identity.
alertedAtis a different thing: when the alert was raised on board. The receiver does not compare it with the clock. The example's detection watch, however, only reads alerts whosealertedAtis within the last 900 seconds, so an alert stamped older than that is stored but raises no detection.
Signing
The signature covers these bytes, in this order, with nothing added:
<x-ssa-signature-timestamp>.<x-ssa-alert-id>.<the request body, byte for byte>The digest is HMAC-SHA256(key = the secret as UTF-8, message = those bytes), sent as hex, optionally prefixed sha256=. A prefix other than sha256= is refused. The body is signed as sent: no canonical form, so sign the final bytes after serialisation and send exactly those.
Because the timestamp and the delivery ID are inside the signed bytes, a captured request cannot be replayed later or under another delivery ID. There is one secret per installed source. The receiver resolves the connection afresh for every delivery, so rotating the secret means editing the connection; the first delivery after that is checked against the new value, and there is no overlap period in which both secrets are accepted.
A worked example. With secret example-shared-secret, delivery ID 7d0f3c2e-5b1a-4a8e-9f61-2c4b8e1d0a93 and timestamp 1791936000 (2026-10-14T00:00:00Z), signing the body shown above on one line without trailing newline gives:
BODY='{"alerts":[{"alertId":"3f0c7c2e-8d1a-4b6e-9a52-7e1d2c4b8f10","mmsi":"000000002","imo":"9000002","shipName":"Demo Aurora Tide","callsign":null,"latitude":51.62,"longitude":2.71,"alertedAt":"2026-10-14T00:00:00.000Z","status":"active","provenance":"demo sender"}]}'
DELIVERY_ID='7d0f3c2e-5b1a-4a8e-9f61-2c4b8e1d0a93'
TS=1791936000
printf '%s.%s.%s' "$TS" "$DELIVERY_ID" "$BODY" | openssl dgst -sha256 -hmac 'example-shared-secret'
# 9e783debd3c597b8cb4b5797c47cc9a4e05b727fda23363d8ef7dc9ba66d0291The request then carries x-ssa-signature: sha256=9e783debd3c597b8cb4b5797c47cc9a4e05b727fda23363d8ef7dc9ba66d0291.
Responses
The checks run in this order; the first that fails answers.
| Status | error | Cause |
|---|---|---|
405 | method_not_allowed | Not a POST. |
404 | push_binding_not_found, push_binding_invalid, push_ingress_off | The binding ID in the address is not valid, unknown, or does not match the installed source; or this deployment takes no pushes. Do not retry. |
401 | push_authentication_failed | The x-ssa-signature header is missing or is not a well-formed digest (64 hex characters, optional sha256= prefix). |
400 | push_delivery_id_required | The delivery ID header is missing or longer than 512 characters. |
401 | push_authentication_failed | The x-ssa-signature-timestamp header is missing or is not 1 to 12 decimal digits. |
401 | push_signature_stale | Signing time more than 300 seconds from the receiver's clock. |
413 | push_body_too_large | Body over 1 MiB. |
400 | push_body_unreadable | The body could not be read to the end. |
503 | push_connection_required, push_credential_unavailable | No usable connection (secret) is configured for the source yet. Retry later. |
401 | push_authentication_failed | The digest over the received bytes does not match the secret. |
429 | push_rate_limited | Too many deliveries. Wait the number of seconds in the Retry-After header, if present. Only a correctly signed delivery counts against the limit. |
400 | push_payload_invalid | The body is not UTF-8 or not JSON, or alerts is missing, null or a scalar. |
413 | push_rows_too_large | More than 1,000 alerts. Nothing is queued. |
503 | push_acceptance_unavailable, ingest_execution_unavailable | The receiver could not durably queue the delivery. Retry the same delivery. |
202 | none; body {"status":202,"receiptId":"...","duplicate":false} | Accepted and durably queued. |
A 202 means the delivery is queued, not that every alert in it is stored. After acceptance each alert is checked against the field table above: an alert that breaks it (a number where mmsi must be a string, a zone-less alertedAt, a missing required field) is set aside on its own, kept for an hour, and the other alerts of the delivery are written. A sender that wants confirmation that an alert reached the table has to read the table or the source's health in Analytics; the response does not say.
Retries and idempotency
- Same delivery ID, same alerts (the timestamp and signature may differ, and so may the raw body bytes: what is compared is the rows the body maps to, together with the source's manifest, descriptor version and binding ID): answered
202with"duplicate": trueand the originalreceiptId, and written once. The ID is remembered for one hour by default after the delivery is written or set aside (the retention is a deployment setting); after that a resend is a new delivery. - Same delivery ID, different alerts: never send this. The receiver refuses the conflict internally and the sender sees
503 push_acceptance_unavailable, which looks like an outage and will repeat for as long as the ID is reused. - Retry on
429,503and network failures, with the same delivery ID and the same body, a fresh timestamp and a growing delay. Do not retry on other4xxwithout fixing the cause. - Deliveries are written in the order they were accepted, one at a time for the source. Alert rows are upserted by
alertId, so a late resend of an alert under a new delivery ID rewrites the same row; a cancel sent before a delayed alert would be overwritten by it. Send the cancel after the alert's202.
Mapping from the public SSA outline
The public outline of a ship security alert (SOLAS chapter XI-2 regulation 6; ISPS Code) is: the alert identifies the ship, gives its position and the time, and states that the security of the ship is under threat or has been compromised. It is sent to a competent authority ashore and not to other ships. Mapped to the contract:
| Public SSA content | Field | Note |
|---|---|---|
| Ship name | shipName | Required. |
| IMO number | imo | Optional. |
| MMSI | mmsi | Required, string. |
| Call sign | callsign | Optional. |
| Position of the ship | latitude, longitude | Decimal degrees, WGS 84 expected. |
| Date and time of the alert | alertedAt | RFC 3339 with zone. |
| Security of the ship is under threat or compromised | an alert with status: "active" | There is no other threat field: the arrival of an active alert is the statement. |
| Alert deactivated or reset on board | an alert with the same alertId and status: "cancelled" | Replaces the alert's row and stops it raising a detection or showing in the app. It does not retract a detection or case already opened: see Alert and cancel. |
| Not part of the outline | alertId, provenance | Added by this contract: identity of the alert and origin of the record. |
The contract has no course, speed, alert type or free-text field.
Where each rule is enforced
Paths are relative to the repository root; each rule is named by the symbol that holds it, because line numbers move.
| Rule | Where |
|---|---|
Source declaration: headers, credential reference, $.alerts, upsert | apps/docs/examples/mik-north-sea/ssa-source.ts: createMikSsaAlertSource, MIK_SSA_DELIVERY_ID_HEADER, MIK_SSA_SIGNATURE_HEADER, MIK_SSA_TIMESTAMP_HEADER, MIK_SSA_CREDENTIAL_REF, MIK_SSA_WIRE_FIELDS |
Sender: body fields, status: "active", fresh delivery ID, Unix-second timestamp, signature | apps/docs/examples/mik-north-sea/ssa-alert.ts: mikSsaAlertForVessel, mikSsaAlertBody, signMikSsaDelivery, sendMikSsaAlert |
| Cancelled alerts are left out of the watch | apps/docs/examples/mik-north-sea/manifest.ts: the mik-ship-security-alert watch, target.exclude; the read in apps/api-ontology/src/kernel/watch/evaluator.ts |
| Public push address and binding ID shape | packages/sdk/src/platform/contracts/data-source-ingest.ts: DATA_SOURCE_INGEST_PATHS; apps/api-ingestion/src/continuous-host.ts: PUSH_BINDING_ID, acceptPush |
| Order of checks and every status above | packages/extension-runtime/src/data-source/push/runtime.ts: accept |
| Timestamp digits only; tolerance either way | runtime.ts: signingTimestamp, withinSignatureTolerance; packages/sdk-authoring/src/data-sources/manifest.ts: PUSH_SIGNATURE_TOLERANCE_SECONDS |
Signed bytes <timestamp>.<delivery ID>.<body>, HMAC, hex and sha256= prefix | packages/common/src/security/inbound-auth.ts: timestampedSigningInput, computeInboundHmac, decodeHmacSignature, inboundCredentialShape, verifyInboundCredential |
| Size and row limits | packages/sdk/src/platform/contracts/data-source-ingest.ts: SOURCE_BATCH_LIMITS; apps/api-ingestion/src/continuous-composition.ts: the limits of the push service |
alerts selection, including a single object read as one alert | packages/extension-runtime/src/data-source/payload.ts: mapDeclarativePayload; packages/common/src/row-codecs/index.ts: toRowCodecItems |
| Duplicate, identity conflict, one-hour memory | packages/ingest-buffer/src/scripts.ts: the pushAccept operation; packages/ingest-buffer/src/valkey.ts: DEFAULT_INGEST_TOMBSTONE_RETENTION_MS and the delivery fingerprint in push.accept |
| Per-alert type and timestamp checks | packages/sdk-authoring/src/data-sources/validator-from-columns.ts: columnSchema; runtime.ts: the validateRow pass that sets a row aside |