openapi: 3.1.0
info:
  title: Nucleo Shipment Tracking API
  version: v1
  summary: Public parcel tracking for shoppers, and the inbound webhook carriers use to report tracking events.
  description: |
    Two sides of shipment tracking in Nucleo Commerce:

    - **Public tracking** — the API behind the Nucleo-hosted tracking page
      (`https://app.nucleoplatform.com/track/{token}`): brand, order, status, estimated delivery, items and
      the event history of one shipment, addressed by a signed token. No login; no personal data beyond the
      shopper's first name and destination city. Use it to show tracking inside your own storefront.
    - **Tracking webhook** — the endpoint a tracking aggregator (Qapla'), DHL, or any other carrier (or
      your own middleware) calls to push parcel events into Nucleo. Each call is authenticated with the
      secret of the carrier connection it is addressed to.

    Tracking events are normalised to one set of statuses for every carrier, update the shipment, mark
    orders as delivered (which starts the return window), open anomalies for problems and trigger the
    merchant's customer notifications (out for delivery, delivered, ready for pickup, delivery problem).
  contact:
    name: Nucleo support
    url: https://nucleoplatform.com
x-nucleo:
  product: tracking
  module: commerce
  audience: [storefront, carrier]
  stability: beta
  format: json
  order: 32
servers:
  - url: https://api-commerce.nucleoplatform.com/api/oms/v1
    description: Production
tags:
  - name: Public tracking
    description: |
      Read-only tracking of one shipment by its signed token.

      **Where the token comes from.** Nucleo builds the tracking link `…/track/{token}` for every shipped
      parcel; it is the link in the shipping notifications Nucleo sends to shoppers and the *tracking page*
      link on the shipment in **Orders** in Nucleo. The token is the last path segment. It is signed by
      Nucleo, cannot be guessed or altered, and does not expire; it stops working only if the shipment is
      voided.
  - name: Carrier webhooks
    description: |
      Inbound tracking events, one URL per carrier connection:
      `https://api-commerce.nucleoplatform.com/api/oms/v1/webhooks/tracking/{connection_id}`.

      The connection (Qapla', DHL or another carrier) and its secret are configured on the merchant's
      account under **Settings › Orders › Channels and logistics**; the connection ID is the UUID of that
      connection. Webhook secrets for carriers and Qapla' are set up with Nucleo during onboarding — ask
      Nucleo support for the URL and the secret.

      Three payload formats are accepted, chosen by the connection type:

      | Connection | Body | Authentication |
      | --- | --- | --- |
      | Qapla' | Qapla' webhook (v1.2/1.3), one event | `apiKey` field in the body = the connection's API key |
      | DHL | DHL Shipment Tracking – Unified (`shipments[]`) | `?key=` query parameter = the connection's webhook secret |
      | Any other carrier | Nucleo generic format (`events[]`) | `X-Nucleo-Signature` header = hex HMAC-SHA256 of the raw body with the connection's webhook secret |
paths:
  /public/tracking/{token}:
    get:
      operationId: getPublicTracking
      summary: Get a shipment's public tracking
      tags: [Public tracking]
      security: []
      description: |
        Status, dates, carrier and up to 50 most recent tracking events (newest first) of one shipment,
        with the merchant's branding. Only what the shopper needs: no email, phone or full address.

        `status` is the normalised status of the latest event; before any event it is `picked_up`
        (or `delivered` if the shipment is already marked delivered).

        Language: `lang` if supported, otherwise the order's language, otherwise one derived from the
        shipping country, otherwise `en`. It is returned as `locale`; event descriptions are shown as the
        carrier sent them.

        Rate limit: 60 requests per minute per IP (`X-RateLimit-*` headers on every
        response). Read-only and idempotent.
      x-rate-limit:
        limit: 60
        window: 1m
        scope: per IP
      parameters:
        - name: token
          in: path
          required: true
          description: Signed tracking token, the last segment of the tracking link.
          schema:
            type: string
            minLength: 20
            maxLength: 200
            pattern: '^[A-Za-z0-9_.-]{20,200}$'
          example: MTA0Mjo3ZjNjMmE5MS00YjZkLTRlMmEtOWMxZi0zZDhlNWEyYjZjNDA.Qm9Yc1Z3Tm1LcFJ0WmFHaEpk
        - name: lang
          in: query
          required: false
          description: Preferred language.
          schema:
            type: string
            enum: [it, en, de, fr, es]
          example: it
      responses:
        '200':
          description: Tracking information.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/PublicTracking'
              examples:
                outForDelivery:
                  value:
                    data:
                      brand:
                        name: Acme Apparel
                        logo_url: https://cdn.acme.example/logo.svg
                        accent_color: '#0F766E'
                        support_email: help@example.com
                        support_url: https://shop.acme.example/pages/contact
                      locale: it
                      order_name: '#1042'
                      customer_first_name: Giulia
                      destination: Milano, IT
                      carrier: DHL Express
                      tracking_numbers: ['1234567890']
                      carrier_url: https://www.dhl.com/it-en/home/tracking.html?tracking-id=1234567890
                      status: out_for_delivery
                      status_at: '2026-10-06T07:42:00+00:00'
                      shipped_at: '2026-10-05T15:10:00+00:00'
                      delivered_at: null
                      estimated_delivery_at: '2026-10-06T18:00:00+00:00'
                      items:
                        - title: Organic Cotton Tee · M
                          quantity: 1
                        - title: Fleece Hoodie · L
                          quantity: 2
                      events:
                        - occurred_at: '2026-10-06T07:42:00+00:00'
                          status: out_for_delivery
                          description: Shipment is out with courier for delivery
                          location: Milano, IT
                        - occurred_at: '2026-10-05T21:03:00+00:00'
                          status: in_transit
                          description: Processed at MILANO - ITALY
                          location: Milano, IT
                        - occurred_at: '2026-10-05T15:10:00+00:00'
                          status: picked_up
                          description: Shipment picked up
                          location: Bologna, IT
        '404':
          description: |
            `not_found`: invalid or tampered token, unknown store, unknown or voided shipment. A token that does
            not match the path pattern gets the generic route-not-found message instead.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TrackingError'
                  - $ref: '#/components/schemas/Message'
              examples:
                notFound:
                  value:
                    error: not_found
        '429':
          $ref: '#/components/responses/Throttled'
  /webhooks/tracking/{connection}:
    post:
      operationId: receiveTrackingEvents
      summary: Push tracking events for a carrier connection
      tags: [Carrier webhooks]
      security:
        - nucleoSignature: []
        - dhlWebhookKey: []
      description: |
        Receives parcel events for the shipments of one carrier connection. The expected body and
        authentication depend on the connection type (see the table in the tag description):

        - **Generic carriers** — body `{"events": [ … ]}` (or a single event object), header
          `X-Nucleo-Signature: hex(HMAC-SHA256(raw body, webhook_secret))`. `status` can be a Nucleo status, a
          carrier code translated by the connection's status map, or empty (Nucleo infers it from
          `description`, in Italian, English, German, French or Spanish).
        - **Qapla'** — the Qapla' webhook body, authenticated by its `apiKey` field (this scheme cannot be
          expressed as an OpenAPI security requirement). The reply is `{"result":"OK"}` as Qapla' expects.
        - **DHL** — the DHL Shipment Tracking – Unified body (`shipments[]`), authenticated by `?key=`.

        **Matching.** Events are matched to shipments by `tracking_number` (the most recent parcel with that
        number). Events for unknown tracking numbers are ignored without error.

        **Idempotency.** An event identical to one already stored (same shipment, tracking number, minute,
        status and description) is ignored, so retries and overlapping deliveries are safe. `applied` counts
        only new events.

        **Effects.** The shipment takes the status of its most recent event. `delivered` marks the shipment
        and order delivered (starting the return window) and closes open tracking anomalies;
        `exception`, `held` and `returned_to_sender` open an anomaly for the merchant. Recent events may send
        the merchant's customer notifications (out for delivery, delivered, ready for pickup, delivery
        problem), each at most once.

        Rate limit: 600 requests per minute per calling IP.
      x-rate-limit:
        limit: 600
        window: 1m
        scope: per IP
      parameters:
        - name: connection
          in: path
          required: true
          description: ID of the carrier or Qapla' connection in Nucleo.
          schema:
            type: string
            format: uuid
          example: 5f0c7e2a-8b3d-4c1e-9a6f-2d4b8e1c7a35
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/GenericWebhook'
                - $ref: '#/components/schemas/GenericEvent'
                - $ref: '#/components/schemas/QaplaWebhook'
                - $ref: '#/components/schemas/DhlWebhook'
            examples:
              generic:
                summary: Generic carrier (signed with X-Nucleo-Signature)
                value:
                  events:
                    - tracking_number: '0612345678901'
                      status: ''
                      description: Consegnata al destinatario
                      occurred_at: '2026-10-06T11:05:00Z'
                      location: Milano
                    - tracking_number: '0612345678902'
                      status: exception
                      exception_code: recipient_absent
                      description: Recipient not at home, second attempt tomorrow
                      occurred_at: '2026-10-06T11:20:00Z'
                      location: Torino
              qapla:
                summary: Qapla' webhook
                value:
                  apiKey: your-qapla-connection-key
                  trackingNumber: '0612345678901'
                  courier: GLS
                  date: '2026-10-06 09:12:00'
                  qaplaStatusID: 4
                  qaplaStatus: IN CONSEGNA
                  courierStatus: In consegna
                  place: Milano
                  statusDetails:
                    - detail: Affidata al corriere per la consegna
              dhl:
                summary: DHL Shipment Tracking – Unified (with ?key=)
                value:
                  shipments:
                    - id: '1234567890'
                      events:
                        - timestamp: '2026-10-06T07:42:00Z'
                          statusCode: transit
                          description: Shipment is out with courier for delivery
                          location:
                            address:
                              addressLocality: Milano
                              countryCode: IT
      responses:
        '200':
          description: Accepted. `applied` is the number of new events stored.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                type: object
                required: [result, applied]
                properties:
                  result:
                    type: string
                    const: OK
                  applied:
                    type: integer
                    minimum: 0
              examples:
                ok:
                  value:
                    result: OK
                    applied: 2
        '400':
          description: The body is not a JSON object.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookError'
              examples:
                invalidJson:
                  value:
                    result: KO
                    error: invalid json
        '401':
          description: Wrong or missing signature / key / `apiKey`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookError'
              examples:
                unauthorized:
                  value:
                    result: KO
                    error: unauthorized
        '404':
          description: |
            No carrier or Qapla' connection with this ID (`unknown connection`). A non-UUID ID gets the generic
            route-not-found message.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/WebhookError'
                  - $ref: '#/components/schemas/Message'
              examples:
                unknownConnection:
                  value:
                    result: KO
                    error: unknown connection
        '429':
          $ref: '#/components/responses/Throttled'
components:
  securitySchemes:
    nucleoSignature:
      type: apiKey
      in: header
      name: X-Nucleo-Signature
      description: |
        Generic carrier connections. Lowercase hex HMAC-SHA256 of the exact raw request body, keyed with the
        connection's webhook secret. Compute it over the bytes you send, after JSON serialisation.
    dhlWebhookKey:
      type: apiKey
      in: query
      name: key
      description: DHL connections. The connection's webhook secret, as a query parameter.
  headers:
    XRateLimitLimit:
      description: Requests allowed per minute.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests left in the current minute.
      schema:
        type: integer
  responses:
    Throttled:
      description: Too many requests from this IP.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema:
            type: integer
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          description: Unix time when the limit resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            throttled:
              value:
                message: Too Many Attempts.
  schemas:
    Message:
      type: object
      required: [message]
      properties:
        message:
          type: string
    TrackingError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          const: not_found
    WebhookError:
      type: object
      required: [result, error]
      properties:
        result:
          type: string
          const: KO
        error:
          type: string
          enum: [unknown connection, invalid json, unauthorized]
    TrackingStatus:
      type: string
      description: |
        Normalised parcel status, the same for every carrier: `pending` (label created, not yet collected),
        `picked_up`, `in_transit`, `out_for_delivery`, `delivered`, `exception` (delivery problem), `held`
        (at a depot or pickup point), `returned_to_sender`.
      enum: [pending, picked_up, in_transit, out_for_delivery, delivered, exception, held, returned_to_sender]
    ExceptionCode:
      type: string
      description: Detail for `exception` and `held`.
      enum: [address_wrong, recipient_absent, refused, damaged, customs, held_at_depot, at_pickup_point, lost, other]
    PublicTracking:
      type: object
      required: [brand, locale, order_name, customer_first_name, destination, carrier, tracking_numbers, carrier_url, status, status_at, shipped_at, delivered_at, estimated_delivery_at, items, events]
      properties:
        brand:
          type: object
          required: [name, logo_url, accent_color, support_email, support_url]
          properties:
            name:
              type: string
              example: Acme Apparel
            logo_url:
              type: [string, 'null']
              format: uri
            accent_color:
              type: string
              description: Hex colour (`#111111` when not set).
              example: '#0F766E'
            support_email:
              type: [string, 'null']
              format: email
            support_url:
              type: [string, 'null']
              format: uri
        locale:
          type: string
          enum: [it, en, de, fr, es]
        order_name:
          type: string
          example: '#1042'
        customer_first_name:
          type: string
          description: Shipping first name, or empty.
          example: Giulia
        destination:
          type: string
          description: '`City, COUNTRY` of the shipping address (either part may be missing).'
          example: Milano, IT
        carrier:
          type: [string, 'null']
          example: DHL Express
        tracking_numbers:
          type: array
          description: One per parcel.
          items:
            type: string
        carrier_url:
          type: [string, 'null']
          format: uri
          description: The carrier's own tracking page, if known.
        status:
          $ref: '#/components/schemas/TrackingStatus'
        status_at:
          type: [string, 'null']
          format: date-time
        shipped_at:
          type: [string, 'null']
          format: date-time
        delivered_at:
          type: [string, 'null']
          format: date-time
        estimated_delivery_at:
          type: [string, 'null']
          format: date-time
        items:
          type: array
          description: Items in this shipment.
          items:
            type: object
            required: [title, quantity]
            properties:
              title:
                type: string
              quantity:
                type: integer
                minimum: 1
        events:
          type: array
          maxItems: 50
          description: Most recent first.
          items:
            type: object
            required: [occurred_at, status, description, location]
            properties:
              occurred_at:
                type: [string, 'null']
                format: date-time
              status:
                $ref: '#/components/schemas/TrackingStatus'
              description:
                type: [string, 'null']
              location:
                type: [string, 'null']
    GenericEvent:
      type: object
      required: [tracking_number]
      properties:
        tracking_number:
          type: string
          description: Parcel tracking number, as stored on the Nucleo shipment.
          example: '0612345678901'
        status:
          type: string
          description: |
            A Nucleo status (see `TrackingStatus`), a carrier status code mapped by the connection's status map,
            or empty to let Nucleo infer it from `description`.
          example: delivered
        description:
          type: string
          description: Event text (stored up to 500 characters).
          example: Delivered to recipient
        occurred_at:
          type: string
          format: date-time
          description: When the event happened. Defaults to the time of receipt.
        location:
          type: string
          description: Where it happened (stored up to 160 characters).
          example: Milano
        exception_code:
          $ref: '#/components/schemas/ExceptionCode'
    GenericWebhook:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/GenericEvent'
    QaplaWebhook:
      type: object
      required: [apiKey, trackingNumber, qaplaStatusID]
      description: |
        Qapla' webhook (documentation v1.2/1.3). `qaplaStatusID` is mapped to Nucleo statuses:
        0/1/2 → pending, 20 → picked_up, 3/8/50 → in_transit, 4 → out_for_delivery, 5 → exception,
        6 → exception or held, 10 → held (pickup point), 95 → returned_to_sender, 99 → delivered.
        `consignee` and `apiKey` are not stored.
      properties:
        apiKey:
          type: string
          description: The Qapla' connection's API key in Nucleo.
        trackingNumber:
          type: string
        date:
          type: string
          description: Event date and time.
          example: '2026-10-06 09:12:00'
        qaplaStatusID:
          type: integer
        qaplaStatus:
          type: string
        courierStatus:
          type: string
        place:
          type: string
        statusDetails:
          type: array
          items:
            type: object
            properties:
              detail:
                type: string
        courier:
          type: string
      additionalProperties: true
    DhlWebhook:
      type: object
      required: [shipments]
      description: DHL Shipment Tracking – Unified push format. `statusCode` and `description` are mapped to Nucleo statuses.
      properties:
        shipments:
          type: array
          items:
            type: object
            required: [id]
            properties:
              id:
                type: string
                description: DHL tracking number.
              events:
                type: array
                items:
                  type: object
                  properties:
                    timestamp:
                      type: string
                      format: date-time
                    statusCode:
                      type: string
                      enum: [pre-transit, transit, delivered, failure, unknown]
                    description:
                      type: string
                    location:
                      type: object
                      properties:
                        address:
                          type: object
                          properties:
                            addressLocality:
                              type: string
                            countryCode:
                              type: string
              status:
                type: object
                description: Latest status, used when `events` is empty (same shape as an event).
      additionalProperties: true
