openapi: 3.1.0

info:
  title: MesColis API
  version: "1.0"
  x-logo:
    url: 'https://www.mescolis.ca/images/Mescolis_logo_box.svg'
    altText: 'MesColis'
    backgroundColor: '#ffffff'
    href: 'https://www.mescolis.ca'
  description: |
    The **MesColis REST API** gives B2B customers programmatic access to carrier rates,
    shipment records, package tracking, and address book management.

    Base URL: **`https://mescolis.ca/api/v1`**

    ---

    ## Quick Start

    **Step 1 — Get your API key**

    Log in to MesColis → **Settings → Developer Hub** → click **Generate Key**.
    Keys are prefixed `mc_live_` and shown **only once** — save it somewhere safe.

    **Step 2 — Compare rates**

    ```bash
    curl "https://mescolis.ca/api/v1/rates?from_postal=H2X1Y1&to_postal=M5V2T6&weight_kg=1.5&length_cm=30&width_cm=20&height_cm=15" \
      -H "Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ```

    **Step 3 — View your shipments**

    ```bash
    curl "https://mescolis.ca/api/v1/shipments" \
      -H "Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ```

    **Step 4 — Track a package**

    ```bash
    curl "https://mescolis.ca/api/v1/track?tracking_number=1Z999AA10123456784&carrier=ups" \
      -H "Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ```

    ---

    ## Authentication

    Every request must include a Bearer API key in the `Authorization` header:

    ```
    Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    ```

    Missing or invalid keys return **401 Unauthorized**.
    Keys lacking the required scope return **403 Forbidden**.

    ---

    ## Scopes

    Each key is issued with a set of scopes. When creating a key in the Developer Hub,
    select only the scopes your integration needs (principle of least privilege).

    | Scope | What it allows |
    |-------|----------------|
    | `rates:read` | Compare shipping rates |
    | `shipments:read` | Read shipment records |
    | `track:read` | Track packages by tracking number |
    | `addresses:read` | Read address book entries |
    | `addresses:write` | Create, update, and delete addresses |

    ---

    ## Rate Limits

    Endpoints are rate-limited **per API key** using a sliding window.
    Exceeding the limit returns **429 Too Many Requests** with a `Retry-After` header
    (seconds until the window resets).

    | Endpoint | Limit |
    |----------|-------|
    | `GET /rates` | 30 req / min |
    | `GET /shipments` | 60 req / min |
    | `GET /shipments/{id}` | 60 req / min |
    | `GET /track` | 60 req / min |
    | `GET /addresses` | 30 req / min |
    | `POST /addresses` | 30 req / min |
    | `GET /addresses/{id}` | 30 req / min |
    | `PATCH /addresses/{id}` | 30 req / min |
    | `DELETE /addresses/{id}` | 30 req / min |

    ---

    ## Pagination

    All list endpoints accept `limit` (max 100) and `offset` query parameters.
    Every list response includes a standard envelope:

    ```json
    {
      "object": "list",
      "data": [...],
      "count": 142,
      "limit": 20,
      "offset": 0,
      "has_more": true
    }
    ```

    To fetch the next page: `?limit=20&offset=20`.

    ---

    ## Errors

    All errors follow the same shape:

    ```json
    { "error": "Human-readable description" }
    ```

    | Status | Meaning |
    |--------|---------|
    | 400 | Bad request — missing or invalid parameter |
    | 401 | Missing or invalid API key |
    | 403 | API key lacks the required scope |
    | 404 | Resource not found or belongs to a different account |
    | 429 | Rate limit exceeded |
    | 500 | Unexpected server error — contact support |

    ---

    ## Support

    **Email:** [support@mescolis.ca](mailto:support@mescolis.ca)
    **Dashboard:** [mescolis.ca](https://mescolis.ca)
    **Status:** [mescolis.ca/help](https://mescolis.ca/help)

  contact:
    name: MesColis Support
    email: support@mescolis.ca
    url: https://mescolis.ca

servers:
  - url: https://mescolis.ca/api/v1
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Rates
    description: |
      Compare real-time shipping rates across all active carriers.
      Rates include your account markup automatically — what you see is what you charge.
  - name: Shipments
    description: |
      Read and filter your shipment records. Shipments are created through the
      MesColis dashboard; this API gives you programmatic read access to all records.
  - name: Tracking
    description: |
      Real-time package tracking by carrier tracking number.
      Returns current status, estimated delivery date, and full checkpoint history.
  - name: Addresses
    description: |
      Full CRUD for your saved address book. Use addresses as quick-fill for the
      shipment creation form in the dashboard.
  - name: Webhooks
    description: |
      MesColis sends `POST` requests to your registered webhook URL when shipment
      events occur. Register your endpoint in **Settings → Developer Hub**.

      All webhook payloads are signed with an `X-MesColis-Signature` header
      (HMAC-SHA256 of the raw body using your webhook secret).

      **Verify signatures before processing:**

      ```js
      const crypto = require('crypto');
      const sig = req.headers['x-mescolis-signature'];
      const expected = crypto
        .createHmac('sha256', process.env.WEBHOOK_SECRET)
        .update(req.body)
        .digest('hex');
      if (sig !== expected) return res.status(401).send('Invalid signature');
      ```

x-tagGroups:
  - name: API Reference
    tags:
      - Rates
      - Shipments
      - Tracking
      - Addresses
  - name: Events
    tags:
      - Webhooks

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: |
        Your MesColis API key prefixed with `mc_live_`.

        **Example:** `Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`

        Generate keys in **Settings → Developer Hub**.

  schemas:

    # ── Address ─────────────────────────────────────────────────────────────

    Address:
      type: object
      description: A saved address in the account address book.
      properties:
        id:
          type: string
          format: uuid
          description: Unique address identifier
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        name:
          type: string
          description: Friendly display name for this address
          example: "Warehouse — Montreal"
        company:
          type: string
          nullable: true
          description: Company name
          example: "Acme Distribution Inc."
        address:
          type: string
          description: Street address line 1
          example: "1234 Rue Wellington"
        address_line2:
          type: string
          nullable: true
          description: Suite, unit, floor, etc.
          example: "Unit 12"
        city:
          type: string
          example: "Montreal"
        province:
          type: string
          description: 2-letter province or state code
          example: "QC"
        postal_code:
          type: string
          example: "H3K 1G7"
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: "CA"
        phone:
          type: string
          nullable: true
          description: Phone number
          example: "+15141234567"
        email:
          type: string
          format: email
          nullable: true
          example: "warehouse@acme.ca"
        is_default:
          type: boolean
          description: Whether this is the account's default from-address
          example: true
        is_residential:
          type: boolean
          description: Residential flag — affects carrier surcharges
          example: false
        created_at:
          type: string
          format: date-time
          example: "2026-01-15T09:00:00Z"

    AddressInput:
      type: object
      required:
        - name
        - address
        - city
        - province
        - postal_code
        - country
      properties:
        name:
          type: string
          description: Friendly display name
          example: "Warehouse — Montreal"
        company:
          type: string
          example: "Acme Distribution Inc."
        address:
          type: string
          description: Street address line 1
          example: "1234 Rue Wellington"
        address_line2:
          type: string
          example: "Unit 12"
        city:
          type: string
          example: "Montreal"
        province:
          type: string
          description: 2-letter province or state code
          example: "QC"
        postal_code:
          type: string
          example: "H3K 1G7"
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: "CA"
        phone:
          type: string
          example: "+15141234567"
        email:
          type: string
          format: email
          example: "warehouse@acme.ca"
        is_default:
          type: boolean
          default: false
          description: |
            Promote to default address. Any existing default is automatically demoted.
            Only one address can be the default at a time.
        is_residential:
          type: boolean
          default: false
          description: Mark as residential — adds carrier residential surcharge to rates.

    AddressPatch:
      type: object
      description: All fields optional — send only the fields you want to update.
      minProperties: 1
      properties:
        name:
          type: string
        company:
          type: string
        address:
          type: string
        address_line2:
          type: string
        city:
          type: string
        province:
          type: string
        postal_code:
          type: string
        country:
          type: string
        phone:
          type: string
        email:
          type: string
          format: email
        is_default:
          type: boolean
          description: Promote to default. Previous default is automatically demoted.
        is_residential:
          type: boolean

    # ── Rate ─────────────────────────────────────────────────────────────────

    Rate:
      type: object
      description: A carrier service rate.
      properties:
        courier_id:
          type: string
          description: Internal carrier slug
          example: "canada_post"
        courier_name:
          type: string
          description: Carrier display name
          example: "Canada Post"
        service_name:
          type: string
          description: Carrier service level
          example: "Expedited Parcel"
        min_delivery_time:
          type: integer
          description: Minimum estimated transit days
          example: 2
        max_delivery_time:
          type: integer
          description: Maximum estimated transit days
          example: 3
        total_charge:
          type: number
          format: float
          description: |
            Final price in CAD with your **account markup already applied**.
            This is the amount that will be charged to your MesColis account.
          example: 18.45
        currency:
          type: string
          example: "CAD"
        rate_id:
          type: string
          description: Use this ID to select a rate when creating a shipment in the dashboard.
          example: "rate_abc123"

    # ── Shipment ─────────────────────────────────────────────────────────────

    Shipment:
      type: object
      description: A shipment record (list view — summary fields).
      properties:
        id:
          type: string
          description: MesColis shipment ID
          example: "SHP-9K2MN7"
        tracking_number:
          type: string
          nullable: true
          description: Carrier-issued tracking number (available once label is generated)
          example: "1Z999AA10123456784"
        status:
          type: string
          enum: [Booked, "In Transit", "Out for Delivery", Delivered, Exception]
          example: "In Transit"
        carrier_name:
          type: string
          example: "UPS"
        recipient_name:
          type: string
          example: "Jane Smith"
        recipient_city:
          type: string
          example: "Toronto"
        recipient_province:
          type: string
          example: "ON"
        recipient_country:
          type: string
          example: "CA"
        total_charge:
          type: number
          format: float
          description: Amount charged to your account in CAD
          example: 22.50
        currency:
          type: string
          example: "CAD"
        created_at:
          type: string
          format: date-time
          example: "2026-04-06T14:30:00Z"
        delivered_at:
          type: string
          format: date-time
          nullable: true
          description: Delivery timestamp — null until delivered
          example: "2026-04-08T11:20:00Z"

    ShipmentDetail:
      allOf:
        - $ref: '#/components/schemas/Shipment'
        - type: object
          description: Full shipment record including label and insurance details.
          properties:
            carrier_id:
              type: string
              description: Internal carrier slug
              example: "ups"
            insurance_fee:
              type: number
              format: float
              nullable: true
              description: Insurance fee charged in CAD (null if no insurance)
              example: 3.25
            label_url:
              type: string
              nullable: true
              description: Signed URL to download the shipping label PDF
              example: "https://mescolis.ca/api/labels/proxy?shipment_id=SHP-9K2MN7"

    # ── Tracking ─────────────────────────────────────────────────────────────

    TrackingCheckpoint:
      type: object
      description: A single tracking event in the shipment's journey.
      properties:
        datetime:
          type: string
          format: date-time
          example: "2026-04-07T08:45:00Z"
        location:
          type: string
          example: "Toronto, ON"
        message:
          type: string
          example: "Out for delivery"

    Tracking:
      type: object
      description: Real-time tracking data for a shipment.
      properties:
        object:
          type: string
          example: "tracking"
        tracking_number:
          type: string
          example: "1Z999AA10123456784"
        carrier:
          type: string
          example: "UPS"
        status:
          type: string
          enum: [Booked, "In Transit", "Out for Delivery", Delivered, Exception]
          example: "In Transit"
        status_label:
          type: string
          description: Human-readable status description
          example: "In Transit"
        estimated_delivery:
          type: string
          format: date
          nullable: true
          description: Carrier-estimated delivery date (YYYY-MM-DD)
          example: "2026-04-08"
        last_update:
          type: string
          format: date-time
          description: Timestamp of the most recent checkpoint
          example: "2026-04-07T08:45:00Z"
        checkpoints:
          type: array
          description: Full event history, newest first
          items:
            $ref: '#/components/schemas/TrackingCheckpoint'

    # ── Pagination ───────────────────────────────────────────────────────────

    ListEnvelope:
      type: object
      description: Standard pagination wrapper returned by all list endpoints.
      properties:
        object:
          type: string
          example: "list"
        count:
          type: integer
          description: Total matching records (across all pages)
          example: 142
        limit:
          type: integer
          description: Page size used for this response
          example: 20
        offset:
          type: integer
          description: Number of records skipped
          example: 0
        has_more:
          type: boolean
          description: "`true` when more records exist beyond this page"
          example: true

    # ── Error ────────────────────────────────────────────────────────────────

    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: Human-readable error description
          example: "from_postal and to_postal are required"

    # ── Webhook payloads ─────────────────────────────────────────────────────

    WebhookPayload:
      type: object
      description: Base structure shared by all webhook events.
      properties:
        event:
          type: string
          description: Event type
          example: "shipment.created"
        created_at:
          type: string
          format: date-time
          example: "2026-04-06T14:30:00Z"
        data:
          type: object
          description: Event-specific payload

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Invalid API key"

    Forbidden:
      description: API key does not have the required scope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Missing required scope: rates:read"

    NotFound:
      description: Resource not found or belongs to a different account
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Shipment SHP-9K2MN7 not found"

    TooManyRequests:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the window resets
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Too many requests — retry after 47 seconds"

    ServerError:
      description: Unexpected server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: "Internal server error — contact support@mescolis.ca"

paths:

  # ────────────────────────────────────────────────────────────────────────────
  # RATES
  # ────────────────────────────────────────────────────────────────────────────

  /rates:
    get:
      operationId: getRates
      summary: Compare shipping rates
      description: |
        Compare real-time carrier rates for a package. Returns all available services
        sorted by price, with your **account markup already applied**.

        Use the `rate_id` from the response to select a rate when creating a shipment
        in the MesColis dashboard.

        > **Tip:** Pass `residential: true` when shipping to a home address — carriers
        > apply a residential surcharge that isn't included by default.

        **Required scope:** `rates:read`
        **Rate limit:** 30 req/min
      tags:
        - Rates
      parameters:
        - name: from_postal
          in: query
          required: true
          description: Origin postal code (spaces optional — `H2X 1Y1` or `H2X1Y1`)
          example: "H2X1Y1"
          schema:
            type: string
        - name: from_country
          in: query
          required: false
          description: Origin country ISO alpha-2 (default `CA`)
          example: "CA"
          schema:
            type: string
            default: "CA"
        - name: to_postal
          in: query
          required: true
          description: Destination postal code
          example: "M5V2T6"
          schema:
            type: string
        - name: to_country
          in: query
          required: false
          description: Destination country ISO alpha-2 (default `CA`)
          example: "CA"
          schema:
            type: string
            default: "CA"
        - name: weight_kg
          in: query
          required: true
          description: Package weight in kilograms
          example: 1.5
          schema:
            type: number
            format: float
            minimum: 0.01
        - name: length_cm
          in: query
          required: true
          description: Package length in centimetres
          example: 30
          schema:
            type: number
            format: float
        - name: width_cm
          in: query
          required: true
          description: Package width in centimetres
          example: 20
          schema:
            type: number
            format: float
        - name: height_cm
          in: query
          required: true
          description: Package height in centimetres
          example: 15
          schema:
            type: number
            format: float
        - name: residential
          in: query
          required: false
          description: Apply residential delivery surcharge (default `false`)
          example: false
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Available rates sorted by price
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                    example: "list"
                  count:
                    type: integer
                    example: 4
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Rate'
              examples:
                montreal_to_toronto:
                  summary: Montreal → Toronto, 1.5 kg
                  value:
                    object: "list"
                    count: 3
                    data:
                      - courier_id: "canada_post"
                        courier_name: "Canada Post"
                        service_name: "Expedited Parcel"
                        min_delivery_time: 2
                        max_delivery_time: 3
                        total_charge: 18.45
                        currency: "CAD"
                        rate_id: "rate_cp_exp_001"
                      - courier_id: "ups"
                        courier_name: "UPS"
                        service_name: "UPS Standard"
                        min_delivery_time: 1
                        max_delivery_time: 2
                        total_charge: 22.10
                        currency: "CAD"
                        rate_id: "rate_ups_std_001"
                      - courier_id: "fedex"
                        courier_name: "FedEx"
                        service_name: "FedEx Ground"
                        min_delivery_time: 2
                        max_delivery_time: 3
                        total_charge: 24.80
                        currency: "CAD"
                        rate_id: "rate_fdx_gnd_001"
        "400":
          description: Missing or invalid query parameters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing_postal:
                  summary: Missing postal code
                  value:
                    error: "from_postal and to_postal are required"
                invalid_weight:
                  summary: Invalid weight
                  value:
                    error: "weight_kg must be a positive number"
                missing_dimensions:
                  summary: Missing dimensions
                  value:
                    error: "length_cm, width_cm, height_cm are required"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  # ────────────────────────────────────────────────────────────────────────────
  # SHIPMENTS
  # ────────────────────────────────────────────────────────────────────────────

  /shipments:
    get:
      operationId: listShipments
      summary: List shipments
      description: |
        Returns all shipments for your account, newest first.
        Filter by status, or paginate using `limit` and `offset`.

        > **Note:** Shipments are created through the MesColis dashboard.
        > This endpoint is for reading records only.

        **Required scope:** `shipments:read`
        **Rate limit:** 60 req/min
      tags:
        - Shipments
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size — max 100, default 20
          example: 20
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
        - name: offset
          in: query
          required: false
          description: Number of records to skip (for pagination)
          example: 0
          schema:
            type: integer
            default: 0
            minimum: 0
        - name: status
          in: query
          required: false
          description: Filter by shipment status
          example: "In Transit"
          schema:
            type: string
            enum:
              - Booked
              - "In Transit"
              - "Out for Delivery"
              - Delivered
              - Exception
      responses:
        "200":
          description: Paginated list of shipments
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Shipment'
              example:
                object: "list"
                count: 142
                limit: 20
                offset: 0
                has_more: true
                data:
                  - id: "SHP-9K2MN7"
                    tracking_number: "1Z999AA10123456784"
                    status: "In Transit"
                    carrier_name: "UPS"
                    recipient_name: "Jane Smith"
                    recipient_city: "Toronto"
                    recipient_province: "ON"
                    recipient_country: "CA"
                    total_charge: 22.50
                    currency: "CAD"
                    created_at: "2026-04-06T14:30:00Z"
                    delivered_at: null
                  - id: "SHP-3XPQ12"
                    tracking_number: "JD014600004513562718"
                    status: "Delivered"
                    carrier_name: "Canada Post"
                    recipient_name: "Marc Tremblay"
                    recipient_city: "Quebec City"
                    recipient_province: "QC"
                    recipient_country: "CA"
                    total_charge: 18.45
                    currency: "CAD"
                    created_at: "2026-04-01T10:00:00Z"
                    delivered_at: "2026-04-03T14:22:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /shipments/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: MesColis shipment ID (e.g. `SHP-9K2MN7`)
        example: "SHP-9K2MN7"
        schema:
          type: string

    get:
      operationId: getShipment
      summary: Get shipment
      description: |
        Retrieve full details for a single shipment, including the label download URL
        and insurance fee.

        Returns **404** if the shipment doesn't exist or belongs to a different account.

        **Required scope:** `shipments:read`
        **Rate limit:** 60 req/min
      tags:
        - Shipments
      responses:
        "200":
          description: Full shipment detail object
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      object:
                        type: string
                        example: "shipment"
                  - $ref: '#/components/schemas/ShipmentDetail'
              examples:
                in_transit:
                  summary: Shipment in transit
                  value:
                    object: "shipment"
                    id: "SHP-9K2MN7"
                    tracking_number: "1Z999AA10123456784"
                    status: "In Transit"
                    carrier_id: "ups"
                    carrier_name: "UPS"
                    recipient_name: "Jane Smith"
                    recipient_city: "Toronto"
                    recipient_province: "ON"
                    recipient_country: "CA"
                    total_charge: 22.50
                    currency: "CAD"
                    insurance_fee: 3.25
                    label_url: "https://mescolis.ca/api/labels/proxy?shipment_id=SHP-9K2MN7"
                    created_at: "2026-04-06T14:30:00Z"
                    delivered_at: null
                delivered:
                  summary: Delivered shipment
                  value:
                    object: "shipment"
                    id: "SHP-3XPQ12"
                    tracking_number: "JD014600004513562718"
                    status: "Delivered"
                    carrier_id: "canada_post"
                    carrier_name: "Canada Post"
                    recipient_name: "Marc Tremblay"
                    recipient_city: "Quebec City"
                    recipient_province: "QC"
                    recipient_country: "CA"
                    total_charge: 18.45
                    currency: "CAD"
                    insurance_fee: null
                    label_url: "https://mescolis.ca/api/labels/proxy?shipment_id=SHP-3XPQ12"
                    created_at: "2026-04-01T10:00:00Z"
                    delivered_at: "2026-04-03T14:22:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  # ────────────────────────────────────────────────────────────────────────────
  # TRACKING
  # ────────────────────────────────────────────────────────────────────────────

  /track:
    get:
      operationId: trackPackage
      summary: Track a package
      description: |
        Get real-time tracking data for any shipment by tracking number.
        Returns the current status, estimated delivery date, and the full
        checkpoint history in reverse-chronological order (newest first).

        > **Tip:** Pass the `carrier` slug to speed up lookup. Without it, MesColis
        > auto-detects the carrier, which adds a small latency.

        Common carrier slugs: `ups`, `canada_post`, `fedex`, `purolator`, `dhl`

        **Required scope:** `track:read`
        **Rate limit:** 60 req/min
      tags:
        - Tracking
      parameters:
        - name: tracking_number
          in: query
          required: true
          description: Carrier-issued tracking number
          example: "1Z999AA10123456784"
          schema:
            type: string
        - name: carrier
          in: query
          required: false
          description: Carrier slug — speeds up lookup (optional but recommended)
          example: "ups"
          schema:
            type: string
      responses:
        "200":
          description: Real-time tracking information with full checkpoint history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tracking'
              examples:
                in_transit:
                  summary: Package in transit
                  value:
                    object: "tracking"
                    tracking_number: "1Z999AA10123456784"
                    carrier: "UPS"
                    status: "In Transit"
                    status_label: "In Transit"
                    estimated_delivery: "2026-04-08"
                    last_update: "2026-04-07T08:45:00Z"
                    checkpoints:
                      - datetime: "2026-04-07T08:45:00Z"
                        location: "Toronto, ON"
                        message: "Out for delivery"
                      - datetime: "2026-04-06T23:10:00Z"
                        location: "Toronto, ON"
                        message: "Arrived at delivery facility"
                      - datetime: "2026-04-06T10:15:00Z"
                        location: "Montreal, QC"
                        message: "Departed from hub"
                      - datetime: "2026-04-06T07:00:00Z"
                        location: "Montreal, QC"
                        message: "Picked up by carrier"
                delivered:
                  summary: Package delivered
                  value:
                    object: "tracking"
                    tracking_number: "JD014600004513562718"
                    carrier: "Canada Post"
                    status: "Delivered"
                    status_label: "Delivered"
                    estimated_delivery: "2026-04-03"
                    last_update: "2026-04-03T14:22:00Z"
                    checkpoints:
                      - datetime: "2026-04-03T14:22:00Z"
                        location: "Quebec City, QC"
                        message: "Delivered to recipient"
                      - datetime: "2026-04-03T08:00:00Z"
                        location: "Quebec City, QC"
                        message: "Out for delivery"
        "400":
          description: Missing required parameter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "tracking_number is required"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: No tracking data found for this tracking number
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "No tracking data found for 1Z999AA10123456784"
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  # ────────────────────────────────────────────────────────────────────────────
  # ADDRESSES
  # ────────────────────────────────────────────────────────────────────────────

  /addresses:
    get:
      operationId: listAddresses
      summary: List addresses
      description: |
        Returns all saved addresses for your account.
        The default address is always returned first, then sorted by creation date (newest first).

        **Required scope:** `addresses:read`
        **Rate limit:** 30 req/min
      tags:
        - Addresses
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size — max 100, default 50
          example: 50
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 100
        - name: offset
          in: query
          required: false
          description: Number of records to skip
          example: 0
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        "200":
          description: Paginated list of addresses
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Address'
              example:
                object: "list"
                count: 3
                limit: 50
                offset: 0
                has_more: false
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    name: "Warehouse — Montreal"
                    company: "Acme Distribution Inc."
                    address: "1234 Rue Wellington"
                    address_line2: "Unit 12"
                    city: "Montreal"
                    province: "QC"
                    postal_code: "H3K 1G7"
                    country: "CA"
                    phone: "+15141234567"
                    email: "warehouse@acme.ca"
                    is_default: true
                    is_residential: false
                    created_at: "2026-01-15T09:00:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

    post:
      operationId: createAddress
      summary: Create an address
      description: |
        Add a new address to the account address book.

        Setting `is_default: true` automatically demotes the current default — only
        one address can be the default at a time.

        **Required scope:** `addresses:write`
        **Rate limit:** 30 req/min
      tags:
        - Addresses
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddressInput'
            examples:
              warehouse:
                summary: Business warehouse
                value:
                  name: "Warehouse — Montreal"
                  company: "Acme Distribution Inc."
                  address: "1234 Rue Wellington"
                  address_line2: "Unit 12"
                  city: "Montreal"
                  province: "QC"
                  postal_code: "H3K 1G7"
                  country: "CA"
                  phone: "+15141234567"
                  email: "warehouse@acme.ca"
                  is_default: true
                  is_residential: false
              residential:
                summary: Residential address
                value:
                  name: "Home Office"
                  address: "56 Chemin des Érables"
                  city: "Laval"
                  province: "QC"
                  postal_code: "H7P 4W5"
                  country: "CA"
                  is_default: false
                  is_residential: true
      responses:
        "201":
          description: Address created successfully
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      object:
                        type: string
                        example: "address"
                  - $ref: '#/components/schemas/Address'
              example:
                object: "address"
                id: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
                name: "Warehouse — Montreal"
                company: "Acme Distribution Inc."
                address: "1234 Rue Wellington"
                address_line2: "Unit 12"
                city: "Montreal"
                province: "QC"
                postal_code: "H3K 1G7"
                country: "CA"
                phone: "+15141234567"
                email: "warehouse@acme.ca"
                is_default: true
                is_residential: false
                created_at: "2026-04-12T10:30:00Z"
        "400":
          description: Missing or invalid field
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing_city:
                  value:
                    error: "'city' is required and must be a non-empty string"
                missing_postal:
                  value:
                    error: "'postal_code' is required and must be a non-empty string"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /addresses/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Address UUID
        schema:
          type: string
          format: uuid
        example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"

    get:
      operationId: getAddress
      summary: Get an address
      description: |
        Retrieve a single address by UUID.

        Returns **404** if the address doesn't exist or belongs to a different account.

        **Required scope:** `addresses:read`
        **Rate limit:** 30 req/min
      tags:
        - Addresses
      responses:
        "200":
          description: Address object
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      object:
                        type: string
                        example: "address"
                  - $ref: '#/components/schemas/Address'
              example:
                object: "address"
                id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                name: "Warehouse — Montreal"
                company: "Acme Distribution Inc."
                address: "1234 Rue Wellington"
                address_line2: "Unit 12"
                city: "Montreal"
                province: "QC"
                postal_code: "H3K 1G7"
                country: "CA"
                phone: "+15141234567"
                email: "warehouse@acme.ca"
                is_default: true
                is_residential: false
                created_at: "2026-01-15T09:00:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

    patch:
      operationId: updateAddress
      summary: Update an address
      description: |
        Update one or more fields on an existing address.
        Send only the fields you want to change — all other fields remain unchanged.

        Setting `is_default: true` automatically demotes the current default.

        **Required scope:** `addresses:write`
        **Rate limit:** 30 req/min
      tags:
        - Addresses
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddressPatch'
            examples:
              change_city:
                summary: Update city and postal code
                value:
                  city: "Laval"
                  postal_code: "H7P 4W5"
              set_default:
                summary: Promote to default address
                value:
                  is_default: true
      responses:
        "200":
          description: Updated address
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      object:
                        type: string
                        example: "address"
                  - $ref: '#/components/schemas/Address'
        "400":
          description: No valid fields provided
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "No valid fields to update"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

    delete:
      operationId: deleteAddress
      summary: Delete an address
      description: |
        Permanently delete an address from the address book.

        **This action cannot be undone.**

        Returns **404** if the address doesn't exist or belongs to a different account.

        **Required scope:** `addresses:write`
        **Rate limit:** 30 req/min
      tags:
        - Addresses
      responses:
        "200":
          description: Address deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                    example: "address"
                  id:
                    type: string
                    example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                  deleted:
                    type: boolean
                    example: true
              example:
                object: "address"
                id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                deleted: true
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

# ────────────────────────────────────────────────────────────────────────────
# WEBHOOKS
# ────────────────────────────────────────────────────────────────────────────

webhooks:

  shipment.created:
    post:
      operationId: webhookShipmentCreated
      summary: Shipment created
      description: |
        Fired when a new shipment is booked in the MesColis dashboard.
        The label may not be generated yet at this point — listen for
        `shipment.label_ready` to confirm the label URL is available.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              event: "shipment.created"
              created_at: "2026-04-06T14:30:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: null
                carrier_name: "UPS"
                service_name: "UPS Standard"
                recipient_name: "Jane Smith"
                recipient_city: "Toronto"
                recipient_province: "ON"
                total_charge: 22.50
                currency: "CAD"
      responses:
        "200":
          description: Acknowledge receipt — return any 2xx to confirm delivery.

  shipment.label_ready:
    post:
      operationId: webhookLabelReady
      summary: Shipping label ready
      description: |
        Fired when the carrier label has been generated and is available for download.
        The `label_url` in the payload is a signed URL valid for 24 hours.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              event: "shipment.label_ready"
              created_at: "2026-04-06T14:31:05Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                label_url: "https://mescolis.ca/api/labels/proxy?shipment_id=SHP-9K2MN7"
      responses:
        "200":
          description: Acknowledge receipt

  shipment.status_updated:
    post:
      operationId: webhookStatusUpdated
      summary: Shipment status updated
      description: |
        Fired when a shipment's tracking status changes.
        Possible statuses: `Booked`, `In Transit`, `Out for Delivery`, `Delivered`, `Exception`.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              event: "shipment.status_updated"
              created_at: "2026-04-07T08:45:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                previous_status: "Booked"
                status: "In Transit"
                carrier_name: "UPS"
                estimated_delivery: "2026-04-08"
      responses:
        "200":
          description: Acknowledge receipt

  shipment.delivered:
    post:
      operationId: webhookDelivered
      summary: Shipment delivered
      description: |
        Fired when a shipment is marked as delivered by the carrier.
        Includes the delivery timestamp reported by the carrier.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              event: "shipment.delivered"
              created_at: "2026-04-08T11:20:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                carrier_name: "UPS"
                recipient_name: "Jane Smith"
                delivered_at: "2026-04-08T11:20:00Z"
      responses:
        "200":
          description: Acknowledge receipt

  shipment.exception:
    post:
      operationId: webhookException
      summary: Shipment exception
      description: |
        Fired when the carrier reports a delivery exception — e.g. address not found,
        recipient unavailable, or shipment held at customs.
        Check the `reason` field for the carrier-provided explanation.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              event: "shipment.exception"
              created_at: "2026-04-08T09:00:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                carrier_name: "UPS"
                reason: "Recipient not available — delivery attempted"
                next_attempt: "2026-04-09"
      responses:
        "200":
          description: Acknowledge receipt
