openapi: 3.0.3
info:
  title: MG Ship Public API
  version: "1.0.0"
  description: |
    Public REST API for the MG Ship Visibility Platform. Returns JSON over HTTPS and
    gives tenant integrations programmatic, read-only access to shipment visibility data
    (listings, full shipment records, and tracking timelines).

    **Versioning** is in the URL path (`/v1`). Breaking changes ship under a new path
    (`/v2`) with a 12-month deprecation window for the prior version.

    **Authentication** is an API key sent as a Bearer token. Keys are issued per tenant
    from the API Portal, shown once, carry explicit scopes (e.g. `shipments.read`), and
    can be rotated or revoked.

    **Plan requirement.** The external API is available on the **Pro** and **Enterprise**
    plans. Requests from a Basic-plan tenant are rejected with `403`
    (`plan.upgrade_required`) — upgrade the plan from the billing page to obtain API access.

    **Rate limits** are per minute by account tier (Bronze 60 / Silver 300 / Gold 1000 /
    Platinum 5000; Enterprise unlimited). Every response carries `X-RateLimit-*` headers.

    **Webhooks** push shipment events (`shipment.created`, `shipment.status_changed`,
    `shipment.exception`) to a URL you register; each is HMAC-signed. See `docs/webhooks.md` and the
    `x-webhooks` section below.

    All monetary amounts are USD in cents.
  contact:
    name: MG Ship API Support
    url: https://api.mglobalship.com/developers
servers:
  - url: https://api.mglobalship.com/api/v1/external
    description: Production
  - url: https://api.staging.mglobalship.com/api/v1/external
    description: Staging
  - url: https://mgs.aipipis.com/api/v1/external
    description: Test (VPS)

security:
  - BearerAuth: []

tags:
  - name: Health
    description: Connectivity and key/tier checks.
  - name: Shipments
    description: Shipment listings, detail, and tracking timelines.

paths:
  /health:
    get:
      tags: [Health]
      summary: Health / key check
      description: Verifies that your API key works and reports your current tier and rate-limit status.
      operationId: getHealth
      responses:
        "200":
          description: OK
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments:
    get:
      tags: [Shipments]
      summary: List shipments
      description: Lists your shipments, newest first, with cursor pagination and optional filters.
      operationId: listShipments
      parameters:
        - name: limit
          in: query
          description: Page size, 1–100.
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: Opaque cursor from a previous page's `next_cursor`. Do not construct it yourself.
          required: false
          schema: { type: string }
        - name: status
          in: query
          description: Filter by shipment status, e.g. `in_transit`.
          required: false
          schema: { type: string }
        - name: q
          in: query
          description: Full-text search across shipment fields.
          required: false
          schema: { type: string }
        - name: mode
          in: query
          description: Transport mode.
          required: false
          schema: { type: string, enum: [air, sea, rail, road] }
        - name: carrier_code
          in: query
          description: Filter by carrier code, e.g. `maersk`.
          required: false
          schema: { type: string }
        - name: po
          in: query
          description: Filter by purchase-order number.
          required: false
          schema: { type: string }
      responses:
        "200":
          description: A page of shipments.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShipmentList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Shipments]
      summary: Create a shipment
      description: |
        Registers a shipment so the platform starts tracking it. Requires the
        `shipments.write` scope. Send a unique `Idempotency-Key` header — a retry
        with the same key returns the original result instead of creating a
        duplicate. The shipment counts against your plan's monthly shipment quota
        (and is billed as overage if you are over the included amount), exactly
        like creating one in the app.

        For `air` shipments the AWB prefix (first 3 digits of `tracking_number`)
        must resolve to a supported airline; a disabled or unknown prefix is
        rejected with `422` (see the response below) rather than created.
      operationId: createShipment
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: A unique key per create request; safe to retry with the same value.
          schema: { type: string, example: a1b2c3d4-e5f6-7890 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentCreate" }
      responses:
        "201":
          description: The created shipment.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShipmentDetail" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnsupportedCarrier" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments/{id}:
    get:
      tags: [Shipments]
      summary: Get a shipment
      description: Retrieves a single shipment with full cargo details and its milestone history.
      operationId: getShipment
      parameters:
        - $ref: "#/components/parameters/ShipmentId"
        - $ref: "#/components/parameters/LocaleParam"
      responses:
        "200":
          description: The shipment.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Content-Language: { description: "The locale actually used for localized fields (e.g. en-US).", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShipmentDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments/{id}/tracking:
    get:
      tags: [Shipments]
      summary: Get tracking timeline
      description: Returns the compact tracking timeline (ordered milestone events) for a shipment.
      operationId: getShipmentTracking
      parameters:
        - $ref: "#/components/parameters/ShipmentId"
        - $ref: "#/components/parameters/LocaleParam"
      responses:
        "200":
          description: The tracking timeline.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Content-Language: { description: "The locale actually used for localized fields (e.g. en-US).", schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tracking" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments/{id}/weather:
    get:
      tags: [Shipments]
      summary: Get weather along route
      description: >
        Returns current/forecast weather (Open-Meteo) at the shipment's origin and
        destination. This is an on-demand enrichment: positions are resolved from the
        shipment's endpoints or milestones, and provider results are cached (~30 min).
        A leg is `null` when its position cannot be resolved. Requires the
        `shipments.read` scope.
      operationId: getShipmentWeather
      parameters:
        - $ref: "#/components/parameters/ShipmentId"
      responses:
        "200":
          description: Weather at origin and destination.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Weather" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /shipments/{id}/risk:
    get:
      tags: [Shipments]
      summary: Get geopolitical risk along route
      description: >
        Returns conflict / civil-unrest events near the shipment's origin and
        destination (within 100 km over the last 7 days). On-demand enrichment with
        cached provider results; a leg is `null` when its position cannot be resolved.
        Requires the `shipments.read` scope.
      operationId: getShipmentRisk
      parameters:
        - $ref: "#/components/parameters/ShipmentId"
      responses:
        "200":
          description: Geopolitical risk at origin and destination.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GeopoliticalRisk" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mgs_live_<key>
      description: 'API key as `Authorization: Bearer mgs_live_<key>`. Created in the API Portal; keep server-side.'

  parameters:
    ShipmentId:
      name: id
      in: path
      required: true
      description: The shipment id.
      schema: { type: integer, format: int64 }
    LocaleParam:
      name: locale
      in: query
      required: false
      description: >
        Optional BCP-47 locale for localized free-text. Only milestone `status_text`
        is localized; all other fields (status, status_code, location,
        timestamps, ids) stay in canonical English. Omitted or unsupported values
        return English (no error). The served locale is echoed in the
        `Content-Language` response header.
      schema:
        type: string
        default: en-US
        enum:
          - en-US
          - bn-BD
          - de-DE
          - fil-PH
          - fr-FR
          - hi-IN
          - id-ID
          - it-IT
          - ja-JP
          - ko-KR
          - ms-MY
          - nl-NL
          - ru-RU
          - th-TH
          - tr-TR
          - vi-VN
          - zh-CN
          - zh-HK

  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Seconds until the rate-limit window resets.
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: auth.token_invalid, message: Token invalid. }
            request_id: req_9a8b7c6d
    Forbidden:
      description: >-
        The key lacks the required scope, the tenant account is not active, or the tenant's
        plan does not include external-API access (`plan.upgrade_required` — requires Pro or
        Enterprise).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: auth.insufficient_scope, message: Key lacks the required scope. }
            request_id: req_9a8b7c6d
    NotFound:
      description: The requested resource does not exist (or is not visible to your tenant).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: shipment.not_found, message: Shipment not found. }
            request_id: req_9a8b7c6d
    RateLimited:
      description: Rate limit exceeded. See `retry_after_seconds` in the body.
      headers:
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: rate_limit_exceeded
              message: Rate limit exceeded for Silver tier (300 req/min). Wait 23s.
              retry_after_seconds: 23
            request_id: req_1f2e3d4c
    ValidationError:
      description: The request body is missing or invalid (or the Idempotency-Key header is absent).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: validation_error, message: "mode must be one of: sea, air, courier." }
            request_id: req_9a8b7c6d
    PaymentRequired:
      description: The shipment quota is exhausted and outstanding overage must be settled before creating more.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: shipment.overage_payment_required, message: Settle the outstanding overage invoice to add more shipments. }
            request_id: req_9a8b7c6d
    Conflict:
      description: A shipment with this tracking number and carrier already exists (or an Idempotency-Key was reused with a different body).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: shipment.duplicate, message: A shipment with this tracking number and carrier already exists. }
            request_id: req_9a8b7c6d
    UnsupportedCarrier:
      description: |
        The air AWB prefix does not resolve to a supported airline, so the platform cannot track it.
        `carrier.airline_not_supported` — the airline exists but has been disabled on the platform;
        `carrier.unknown_awb_prefix` — no airline is registered for this AWB prefix. The `awb_prefix`
        field echoes the offending 3-digit prefix. This is a permanent rejection, not a transient error —
        do not retry with the same AWB.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: carrier.airline_not_supported, message: "Airline Carpatair (V3) is not supported for tracking. AWB prefix 021 has been disabled on this platform.", awb_prefix: "021", airline_name: Carpatair, iata: V3 }
            request_id: req_9a8b7c6d

  schemas:
    ShipmentCreate:
      type: object
      description: Payload to register a shipment for tracking.
      required: [tracking_number, carrier_code, mode]
      properties:
        tracking_number: { type: string, example: "160-12345675" }
        carrier_code:
          type: string
          description: Carrier/airline code (e.g. a PCS code, an IATA airline code, or an AfterShip courier slug).
          example: pcs
        mode: { type: string, enum: [sea, air, courier], example: air }
        origin: { $ref: "#/components/schemas/Place" }
        destination: { $ref: "#/components/schemas/Place" }
        po_number: { type: string, nullable: true, example: PO-2026-001 }
        flight_number: { type: string, nullable: true }
        vessel_flight: { type: string, nullable: true }
        booking_ref: { type: string, nullable: true }
        shipper_name: { type: string, nullable: true }
        consignee_name: { type: string, nullable: true }
    Error:
      type: object
      description: Standard error envelope. The error object always carries a stable `code` and a human-readable `message`; some errors add context fields (e.g. `retry_after_seconds`).
      required: [error, request_id]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: auth.token_invalid }
            message: { type: string, example: Token invalid. }
            retry_after_seconds:
              type: integer
              description: Present only on 429 responses.
              example: 23
        request_id: { type: string, example: req_9a8b7c6d }

    Health:
      type: object
      required: [status, tenant_id, tier]
      properties:
        status: { type: string, example: ok }
        tenant_id: { type: integer, example: 123 }
        tier:
          type: string
          example: silver
          enum: [bronze, silver, gold, platinum, enterprise, unknown]
        rate_limit:
          type: object
          nullable: true
          properties:
            limit: { type: integer, example: 300 }
            remaining: { type: integer, example: 299 }
            reset_seconds: { type: integer, example: 59 }

    Place:
      type: object
      description: An origin or destination location.
      properties:
        country: { type: string, nullable: true, example: CN }
        city: { type: string, nullable: true, example: Shanghai }
        port_code: { type: string, nullable: true, description: "Location code — IATA airport code (air) or UN/LOCODE port code (sea).", example: CNSHA }

    Milestone:
      type: object
      properties:
        object: { type: string, example: milestone }
        status_code: { type: string, example: PICKED_UP }
        status_text: { type: string, example: Picked up }
        location: { type: string, nullable: true, description: "Human-readable location as reported by the carrier.", example: "(HKG) Hong Kong - International Airport (HKIA), CHINA" }
        location_code: { type: string, nullable: true, description: "Normalized location code — IATA airport code (air) or UN/LOCODE port code (sea). Null when the carrier didn't provide one.", example: HKG }
        location_lat: { type: number, format: float, nullable: true, example: 31.2304 }
        location_lng: { type: number, format: float, nullable: true, example: 121.4737 }
        occurred_at: { type: string, format: date-time, nullable: true, description: "The event's LOCAL time (carrier-local offset), matching the carrier's own tracking page — e.g. an HKG event reads +08:00. Falls back to UTC (+00:00) when no offset was captured. (System timestamps like created_at/estimated_arrival are UTC.)", example: "2026-06-29T06:47:00+08:00" }

    ShipmentLinks:
      type: object
      properties:
        self: { type: string, example: /api/v1/external/shipments/12345 }
        tracking: { type: string, example: /api/v1/external/shipments/12345/tracking }

    Shipment:
      type: object
      description: Shipment summary (list representation).
      properties:
        object: { type: string, example: shipment }
        id: { type: integer, format: int64, example: 12345 }
        tracking_number: { type: string, example: MAEU1234567 }
        carrier_code: { type: string, example: maersk }
        mode: { type: string, example: sea }
        status: { type: string, example: in_transit }
        origin: { $ref: "#/components/schemas/Place" }
        destination: { $ref: "#/components/schemas/Place" }
        po_number: { type: string, nullable: true, example: PO-2026-001 }
        container_no: { type: string, nullable: true, example: MAEU123456789 }
        awb_number: { type: string, nullable: true, example: null }
        estimated_arrival: { type: string, format: date-time, nullable: true, example: "2026-04-30T14:00:00+08:00" }
        actual_arrival: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }
        _links: { $ref: "#/components/schemas/ShipmentLinks" }

    Cargo:
      type: object
      properties:
        weight_kg: { type: number, format: float, nullable: true, example: 18000.5 }
        volume_cbm: { type: number, format: float, nullable: true, example: 45.25 }
        pieces: { type: integer, nullable: true, example: 120 }
        description: { type: string, nullable: true, example: Electronics }
        hs_code: { type: string, nullable: true, example: "8471.30" }
        container_size: { type: string, nullable: true, example: 40HQ }
        declared_value:
          type: object
          nullable: true
          description: Declared value. `amount` is USD in cents.
          properties:
            amount: { type: integer, example: 499900 }
            currency: { type: string, example: USD }
        special_handling: { type: string, example: none }
        insurance_level: { type: string, example: none }

    ShipmentDetail:
      allOf:
        - $ref: "#/components/schemas/Shipment"
        - type: object
          description: Full shipment detail (adds cargo, parties, and milestone history).
          properties:
            tracking_mode: { type: string, enum: [auto, external], example: external, description: "auto = tracked via integrated providers (milestones populated); external = carrier tracked only on its own site (no milestones; see external_tracking)." }
            external_tracking:
              nullable: true
              allOf: [{ $ref: "#/components/schemas/ExternalTracking" }]
            vessel_flight: { type: string, nullable: true }
            vessel_name: { type: string, nullable: true, example: MSC GULSUN }
            voyage_number: { type: string, nullable: true, example: 2026W0105 }
            flight_number: { type: string, nullable: true }
            incoterms: { type: string, nullable: true, example: FOB }
            booking_ref: { type: string, nullable: true }
            delivery_deadline: { type: string, format: date-time, nullable: true }
            shipper_name: { type: string, nullable: true }
            consignee_name: { type: string, nullable: true }
            cargo: { $ref: "#/components/schemas/Cargo" }
            milestones:
              type: array
              items: { $ref: "#/components/schemas/Milestone" }

    ShipmentList:
      type: object
      required: [object, data, has_more, next_cursor, _links]
      properties:
        object: { type: string, example: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/Shipment" }
        has_more: { type: boolean, example: false }
        next_cursor: { type: string, nullable: true, example: null }
        _links:
          type: object
          properties:
            self: { type: string, example: /api/v1/external/shipments }
            next: { type: string, example: "/api/v1/external/shipments?cursor=eyJhZnRlciI6MTIzfQ" }

    Tracking:
      type: object
      required: [object, shipment_id, status, events, _links]
      properties:
        object: { type: string, example: tracking }
        shipment_id: { type: integer, format: int64, example: 12345 }
        status: { type: string, example: in_transit }
        tracking_mode: { type: string, enum: [auto, external], example: external, description: "external shipments have no events; use external_tracking to reach the carrier's own site." }
        estimated_arrival: { type: string, format: date-time, nullable: true, example: "2026-04-30T14:00:00+08:00" }
        last_event_at: { type: string, format: date-time, nullable: true, example: "2026-04-23T06:00:00+08:00" }
        events:
          type: array
          items: { $ref: "#/components/schemas/Milestone" }
        external_tracking:
          nullable: true
          allOf: [{ $ref: "#/components/schemas/ExternalTracking" }]
        _links:
          type: object
          properties:
            self: { type: string, example: /api/v1/external/shipments/12345/tracking }
            shipment: { type: string, example: /api/v1/external/shipments/12345 }

    ExternalTracking:
      type: object
      description: How to track an external (carrier-own-website) shipment. Present (non-null) only when tracking_mode = external and the carrier has a tracking URL on file.
      required: [method, url, param, deep_link]
      properties:
        method: { type: string, enum: [GET, POST, MANUAL], example: GET, description: "GET = open deep_link; POST = submit param=tracking_number to url; MANUAL = open url and enter the number by hand." }
        url: { type: string, example: "https://www.carrier.com/track" }
        param: { type: string, nullable: true, example: ref }
        deep_link: { type: string, nullable: true, example: "https://www.carrier.com/track?ref=ABC123" }

    WeatherLeg:
      type: object
      nullable: true
      description: Weather at one leg, or null when the position could not be resolved.
      properties:
        label: { type: string, example: "Singapore, SG" }
        lat: { type: number, format: float, example: 1.29 }
        lng: { type: number, format: float, example: 103.85 }
        observed_at: { type: string, format: date-time, example: "2026-06-25T12:00:00+00:00" }
        temperature_c: { type: number, format: float, example: 29.4 }
        wind_mps: { type: number, format: float, example: 2.7 }
        condition_id: { type: integer, example: 803 }
        condition_main: { type: string, example: Clouds }
        severity: { type: string, enum: [low, moderate, high, severe], example: low }
        summary: { type: string, example: Clouds }
        alerts_count: { type: integer, example: 0 }

    Weather:
      type: object
      required: [object, shipment_id, origin, destination, _links]
      properties:
        object: { type: string, example: weather }
        shipment_id: { type: integer, format: int64, example: 12345 }
        origin: { $ref: "#/components/schemas/WeatherLeg" }
        destination: { $ref: "#/components/schemas/WeatherLeg" }
        _links:
          type: object
          properties:
            self: { type: string, example: /api/v1/external/shipments/12345/weather }
            shipment: { type: string, example: /api/v1/external/shipments/12345 }

    RiskEvent:
      type: object
      properties:
        occurred_on: { type: string, format: date, example: "2026-06-21" }
        type: { type: string, example: violence_civilians }
        sub_type: { type: string, nullable: true, example: "Attack" }
        location: { type: string, example: "Singapore" }
        fatalities: { type: integer, example: 0 }
        distance_km: { type: number, format: float, example: 12.0 }
        description: { type: string, example: "High: 1 violence_civilians event" }

    RiskLeg:
      type: object
      nullable: true
      description: Geopolitical risk at one leg, or null when the position could not be resolved.
      properties:
        label: { type: string, example: "New York, US" }
        lat: { type: number, format: float, example: 40.71 }
        lng: { type: number, format: float, example: -74.0 }
        observed_at: { type: string, format: date-time, example: "2026-06-25T12:00:00+00:00" }
        severity: { type: string, enum: [none, low, medium, high, critical], example: high }
        radius_km: { type: integer, example: 100 }
        since_days: { type: integer, example: 7 }
        summary: { type: string, example: "Critical: 2 battles events, 3 protests events" }
        events_count: { type: integer, example: 8 }
        top_events:
          type: array
          items: { $ref: "#/components/schemas/RiskEvent" }

    GeopoliticalRisk:
      type: object
      required: [object, shipment_id, origin, destination, _links]
      properties:
        object: { type: string, example: geopolitical_risk }
        shipment_id: { type: integer, format: int64, example: 12345 }
        origin: { $ref: "#/components/schemas/RiskLeg" }
        destination: { $ref: "#/components/schemas/RiskLeg" }
        _links:
          type: object
          properties:
            self: { type: string, example: /api/v1/external/shipments/12345/risk }
            shipment: { type: string, example: /api/v1/external/shipments/12345 }
    WebhookEnvelope:
      type: object
      required: [id, event, created_at, data]
      description: Common envelope for every webhook delivery. Signed via the X-MGS-Signature header (HMAC-SHA256 over "<timestamp>.<raw_body>"). See docs/webhooks.md.
      properties:
        id: { type: string, description: "Delivery id; identical across retries — use for idempotency.", example: 5c31ad6295d76fbd3c3cf6b720 }
        event: { type: string, enum: [shipment.created, shipment.status_changed, shipment.exception] }
        created_at: { type: string, format: date-time, description: "Emit time (UTC, ISO 8601).", example: "2026-06-23T03:27:08+00:00" }
        data: { type: object }
    WebhookShipment:
      allOf:
        - $ref: "#/components/schemas/Shipment"
        - type: object
          properties:
            previous_status: { type: string, description: "Prior status (shipment.status_changed only).", example: out_for_delivery }
    ShipmentCreatedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            event: { type: string, example: shipment.created }
            data: { $ref: "#/components/schemas/Shipment" }
    ShipmentStatusChangedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            event: { type: string, example: shipment.status_changed }
            data: { $ref: "#/components/schemas/WebhookShipment" }
    ShipmentExceptionEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            event: { type: string, example: shipment.exception }
            data:
              type: object
              properties:
                shipment:
                  type: object
                  properties:
                    id: { type: integer, format: int64, example: 411 }
                exception:
                  type: object
                  properties:
                    id: { type: integer, example: 87 }
                    type: { type: string, example: delay }
                    severity: { type: string, enum: [low, medium, high, critical], example: high }
                    title: { type: string, example: Shipment delayed at transshipment port }

# Outbound webhooks (rendered as first-class webhooks by Redoc/Bump; see docs/webhooks.md for the
# full guide, signature verification samples, and retry policy). Kept under x-webhooks to remain
# valid OpenAPI 3.0.3.
x-webhooks:
  shipment.created:
    post:
      summary: shipment.created
      description: Sent when a shipment is created. Body is signed with X-MGS-Signature (HMAC-SHA256).
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentCreatedEvent" }
      responses:
        "200": { description: Return any 2xx to acknowledge receipt. }
  shipment.status_changed:
    post:
      summary: shipment.status_changed
      description: "Sent when a shipment's overall status changes; data includes previous_status."
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentStatusChangedEvent" }
      responses:
        "200": { description: Return any 2xx to acknowledge receipt. }
  shipment.exception:
    post:
      summary: shipment.exception
      description: Sent when an exception is detected on a shipment.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentExceptionEvent" }
      responses:
        "200": { description: Return any 2xx to acknowledge receipt. }
