openapi: 3.1.0

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

    Base URL: **`https://www.my3plmanagement.com/api/v1`**

    ---

    ## Quick Start

    **Step 1 — Get your API key**

    Log in to 3PL Management → **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://www.my3plmanagement.com/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"
    ```

    > **Adding insurance or a signature?** They must be priced into THIS call, not
    > added later — `rate_id` is signed, so the price is fixed the moment it is
    > issued. Append `&insured_value=320` (the declared value to cover, 0–5000)
    > and/or `&signature_required=true`, then set the matching flag in Step 3.
    >
    > The rate comes back with `insurance_fee` (the premium, **additive** to
    > `total_charge`) and `insurance_coverage` (what it covers, after the clamp).
    > `insured_value` is COVERAGE, not the premium — insuring a $320 parcel costs
    > a couple of dollars, not $320.

    **Step 3 — Buy a label**

    Take the `rate_id` from Step 2 and send it straight back. You never send a
    price: the `rate_id` carries the quoted price, signed. Requires the
    `shipments:write` scope and a funded wallet.

    ⚠️ If you quoted with `insured_value`, add `"insurance": true` here — and if you
    quoted with `signature_required`, add `"signature_required": true`. **Both halves
    are required:** `insurance: true` against a rate quoted WITHOUT `insured_value`
    charges nothing and covers nothing.

    ```bash
    curl -X POST "https://www.my3plmanagement.com/api/v1/shipments" \
      -H "Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "rate_id": "<rate_id from step 2>",
        "reference": "ORDER-1042",
        "from": {"name":"Warehouse","address":"1500 Bd Saint-Martin O","city":"Laval",
                 "province":"QC","postal":"H7S 1M9","country":"CA",
                 "phone":"5145550142","email":"shipping@example.com"},
        "to":   {"name":"Marie Tremblay","address":"425 Rue Sainte-Catherine E","city":"Montreal",
                 "province":"QC","postal":"H2L 2C4","country":"CA",
                 "phone":"5145550188","email":"marie@example.com","residential":true},
        "packages": [{"weight_kg":1.2,"length_cm":25,"width_cm":18,"height_cm":10,
                      "description":"Refurbished phone","declared_value":320}]
      }'
    ```

    **Step 4 — View your shipments**

    ```bash
    curl "https://www.my3plmanagement.com/api/v1/shipments" \
      -H "Authorization: Bearer mc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    ```

    **Step 5 — Track a package**

    ```bash
    curl "https://www.my3plmanagement.com/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 |
    | `shipments:write` | **Buy labels.** Spends real money — see below |
    | `track:read` | Track packages by tracking number |
    | `addresses:read` | Read address book entries |
    | `addresses:write` | Create, update, and delete addresses |

    ### About `shipments:write`

    This is the only scope that moves money: a call to `POST /shipments` buys a real
    carrier label and debits your 3PL Management wallet. Three consequences worth knowing
    before you enable it:

    - **It is never granted by default.** Creating a key without naming its scopes
      gives you the five read/address scopes above. You must ask for
      `shipments:write` explicitly.
    - **It cannot be granted to a third-party app.** OAuth consent screens can only
      issue read scopes, so an app you authorise can never buy labels on your behalf.
    - **Fund your wallet first.** API purchases settle from your prepaid wallet — the
      only payment method that works with no human at a checkout. Enable auto-reload
      so an unattended integration cannot stall on an empty balance.

    ---

    ## 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 |
    | `POST /shipments` | **5 req / min** — see note |
    | `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 |

    > **Note on `POST /shipments`:** the endpoint's own limiter allows 20 req/min,
    > but the underlying purchase path caps every account at **5 label purchases
    > per minute**, so 5/min is the ceiling you will actually hit. Queue bulk work
    > rather than bursting it.

    ---

    ## 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@my3plmanagement.com](mailto:support@my3plmanagement.com)
    **Dashboard:** [my3plmanagement.com](https://www.my3plmanagement.com)
    **Status:** [my3plmanagement.com/help](https://www.my3plmanagement.com/help)

  contact:
    name: 3PL Management Support
    email: support@my3plmanagement.com
    url: https://www.my3plmanagement.com

servers:
  - url: https://www.my3plmanagement.com/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: Pickups
    description: |
      See and cancel the carrier pickups booked on your account. Booking happens
      on `POST /shipments`.
  - name: Shipments
    description: |
      Buy labels with `POST /shipments`, then read and filter your shipment
      records. Buying requires the `shipments:write` scope and settles from your
      prepaid wallet.
  - 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: |
      3PL Management 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-3PL-Signature` header
      (HMAC-SHA256 of the raw body using your webhook secret).

      **Verify signatures before processing:**

      ⚠️ The header value is prefixed `sha256=`. An earlier version of this
      example compared the raw header against a bare hex digest, which never
      matches — it would have rejected every legitimate delivery.

      ```js
      const crypto = require('crypto');

      // req.body must be the RAW body, not a parsed object — re-serialising
      // JSON changes the bytes and therefore the digest.
      const expected = 'sha256=' + crypto
        .createHmac('sha256', process.env.WEBHOOK_SECRET)
        .update(req.body)
        .digest('hex');

      const sig = req.headers['x-3pl-signature'] || '';
      const ok =
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));

      if (!ok) return res.status(401).send('Invalid signature');
      ```

      Deliveries are **at-least-once**: 3 attempts (immediate, +2 s, +8 s), and an
      endpoint that fails 10 times consecutively is disabled automatically.
      Deduplicate on the top-level `id` and return 2xx promptly.

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 3PL Management API key prefixed with `mc_live_`.

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

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

  schemas:

    # ── Shipment creation input ─────────────────────────────────────────────

    ShipmentAddress:
      type: object
      description: |
        An address on a label. Sent per shipment — there is no stored default, so a
        centralised return address is simply the same `from` block on every call.
      required: [name, address, city, province, postal]
      properties:
        name:
          type: string
          description: Contact name. Carriers truncate beyond ~22 characters.
          example: "CoinStation Warehouse"
        company:
          type: string
          example: "CoinStation"
        address:
          type: string
          example: "1500 Boulevard Saint-Martin O"
        address_line2:
          type: string
          description: Apartment, suite or unit.
          example: "Suite 200"
        city:
          type: string
          example: "Laval"
        province:
          type: string
          description: Province or state code.
          example: "QC"
        postal:
          type: string
          description: Postal or ZIP code. Spaces optional.
          example: "H7S 1M9"
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code. Defaults to `CA`.
          default: "CA"
          example: "CA"
        phone:
          type: string
          example: "5145550142"
        email:
          type: string
          example: "shipping@example.com"
        residential:
          type: boolean
          description: |
            Destination only. Carriers apply a residential surcharge — set the same
            value you quoted with, or the price can differ from the quote.
          example: true

    ShipmentPackage:
      type: object
      description: One parcel, in metric units (kg and cm).
      required: [weight_kg, length_cm, width_cm, height_cm]
      properties:
        weight_kg:      { type: number, example: 1.2 }
        length_cm:      { type: number, example: 25 }
        width_cm:       { type: number, example: 18 }
        height_cm:      { type: number, example: 10 }
        description:
          type: string
          maxLength: 200
          description: Contents description, shown on customs paperwork.
          example: "Refurbished phone"
        declared_value:
          type: number
          description: Declared value in the shipment currency.
          example: 320
        quantity:
          type: integer
          default: 1
        hs_code:
          type: string
          description: |
            Harmonised System code, digits only. Required for accurate duty on
            international lanes; 8 or 10 digits.
          example: "8517120000"
        origin_country:
          type: string
          description: ISO 3166-1 alpha-2 country of manufacture. Defaults to the origin country.
          example: "CA"
        category:
          type: string
          example: "electronics"
        package_type:
          type: string
          enum: [package, envelope, courier_pak]
          default: package
          description: |
            The box KIND, which carriers price differently — an envelope or
            courier pak is cheaper than a parcel.

            🔴 **Quote with the same value you buy with.** It is signed into the
            `rate_id` alongside the dimensions, so quoting an envelope and
            shipping a parcel is refused with a 400 rather than billed at the
            envelope price. An unrecognised value falls back to `package`.

    # ── 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: |
        One carrier service option. This is the exact shape `GET /rates` returns —
        it was rewritten in the same release that added `POST /shipments`, so an
        older integration built against `courier_name` / `service_name` /
        `min_delivery_time` should move to `carrier` / `service` / `estimated_days`.
      properties:
        rate_id:
          type: string
          description: |
            Opaque signed token for THIS rate. Send it to `POST /shipments` to buy
            exactly this option at exactly this price — you never send a price
            yourself. Valid **2 hours**.
          example: "eyJjb3VyaWVyX2lkIjoiLi4uIn0.aBcDeF1234"
        courier_id:
          type: string
          description: Carrier identifier for this rate.
          example: "canada_post"
        carrier:
          type: string
          description: Carrier display name.
          example: "Canada Post"
        service:
          type: string
          description: Carrier service level.
          example: "Expedited Parcel"
        total_charge:
          type: number
          format: float
          description: |
            Final price with your **account markup already applied** — the amount
            your account is charged. **Excludes insurance**; see `insurance_fee`.
          example: 18.45
        currency:
          type: string
          example: "CAD"
        estimated_days:
          type: string
          nullable: true
          description: Carrier's own transit estimate, as text. Null when the carrier gives none.
          example: "2 business days"
        insurance_fee:
          type: number
          format: float
          nullable: true
          description: |
            The insurance premium **for this rate**, marked up, in the same currency
            as `total_charge`. Add it to `total_charge` for the amount you will be
            charged when you buy with `insurance: true`.

            **`null` means this quote carries no premium** — either you did not send
            `insured_value`, or the carrier quoted none for it. Buying with
            `insurance: true` on such a rate adds nothing and covers nothing, so read
            `insurance_coverage` rather than inferring.
          example: 2.75
        insurance_coverage:
          type: number
          description: |
            What `insurance_fee` actually covers — the `insured_value` you sent, after
            the 0–5000 clamp. **`0` means uninsured.** Full replacement value; claims
            within 30 days of delivery.
          example: 320
        signature_required:
          type: boolean
          description: |
            Whether this quote was priced with signature on delivery. Echoed so you can
            confirm the surcharge is in `total_charge` before buying.
          example: false
        parcel_count:
          type: integer
          description: |
            How many parcels this rate covers. The `rate_id` is signed against
            exactly these parcels, so `POST /shipments` will only accept a body
            with the same ones.
          example: 2
        handover_options:
          type: array
          nullable: true
          description: |
            How this carrier accepts the parcel — pass one as `handover`. Some
            carriers support only one, and an impossible choice is corrected server-side
            rather than failing the purchase.
          items:
            type: string
            enum: [dropoff, pickup, free_pickup, paid_pickup]
          example: ["dropoff"]

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

    Shipment:
      type: object
      description: A shipment record (list view — summary fields).
      properties:
        id:
          type: string
          description: 3PL Management 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://www.my3plmanagement.com/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@my3plmanagement.com"

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**.

        Pass the `rate_id` from the response straight to `POST /shipments` to buy
        that exact rate. The `rate_id` is a signed token that carries the quoted
        price, so the shipment is charged at the price you were quoted — your
        integration never sends a price, and cannot be charged a different one.

        A `rate_id` is valid for **2 hours**. After that, request a fresh quote.

        > **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: package_type
          in: query
          required: false
          description: |
            Box kind — `package` (default), `envelope` or `courier_pak`. Cheaper
            for the latter two, and signed into the `rate_id`, so buy with the
            same value you quoted with.
          example: "package"
          schema:
            type: string
            enum: [package, envelope, courier_pak]
            default: package
        - name: residential
          in: query
          required: false
          description: Apply residential delivery surcharge (default `false`)
          example: false
          schema:
            type: boolean
            default: false
        - name: insured_value
          in: query
          required: false
          description: |
            Declared value to insure, in the quote currency. **Omit for an uninsured
            quote** — `insurance_fee` is then `null` and buying with `insurance: true`
            adds nothing and covers nothing.

            Clamped to **0–5000**; the value actually used comes back as
            `insurance_coverage`, so a request above the cap tells you what you got.

            The premium must be priced into the quote you buy from — `rate_id` is
            signed, and insurance cannot be added afterwards at purchase.
          example: 320
          schema:
            type: number
            minimum: 0
            maximum: 5000
        - name: signature_required
          in: query
          required: false
          description: |
            Price the signature-on-delivery surcharge into the quote. Send the same
            value to `POST /shipments`: the flag that rates a shipment must be the
            flag that buys it, or the surcharge is not in the price you were quoted.
          example: false
          schema:
            type: boolean
            default: false
        - name: declared_value
          in: query
          required: false
          description: |
            **International lanes only.** Customs value of the parcel. Without it a
            cross-border quote is priced against a hardcoded **$10** declaration
            while the purchase declares your real value — so the quote can list
            carriers that will not accept the goods.

            Ignored on domestic lanes, which use their own fixed customs item.
          example: 320
          schema:
            type: number
        - name: hs_code
          in: query
          required: false
          description: |
            **International lanes only.** Harmonised System code, digits only.
            **6 digits minimum** — anything shorter is dropped rather than sent, so
            a partial code cannot misdeclare the goods. Send the same code in the
            parcel at purchase.
          example: "8517120000"
          schema:
            type: string
        - name: category
          in: query
          required: false
          description: |
            **International lanes only.** Goods category, used when no HS code is
            given. Matters more than it looks: battery-bearing categories
            (`mobiles`, `tablets`, `computers_laptops`, `cameras`,
            `accessory_battery`) are handled differently by carriers, so quoting a
            phone as the default `home_decor` can show services that refuse it.
          example: "mobiles"
          schema:
            type: string
        - name: description
          in: query
          required: false
          description: |
            **International lanes only.** Goods description for the customs line.
            Defaults to "General goods".
          example: "Refurbished phone"
          schema:
            type: string
      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
  # ────────────────────────────────────────────────────────────────────────────

    post:
      operationId: createRateQuote
      summary: Quote rates for one or more parcels
      description: |
        The same quote as `GET /rates`, with a JSON body instead of query
        parameters — and the only way to quote **more than one parcel**.

        An array of parcels cannot be expressed in a query string, which is why
        the GET is limited to one. (A GET with a body is not the alternative:
        RFC 9110 §9.3.1 says clients should not send content in a GET, browsers'
        `fetch()` throws on it, and intermediaries may strip it.)

        ### Why this matters for buying

        The `rate_id` returned here is signed against **the parcels as well as
        the price**. `POST /shipments` recomputes that fingerprint from the body
        it is given and refuses a mismatch — so quoting three boxes here is what
        lets you buy exactly those three. A `rate_id` from the single-parcel GET
        can only ever buy one parcel.

        Parcel order does not matter; the same boxes in a different order are the
        same shipment.

        **Required scope:** `rates:read`
        **Rate limit:** 30 req/min — **shared with `GET /rates`**, not additional
        to it. Both verbs count against one 30/min budget per account.
      tags:
        - Rates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to, packages]
              properties:
                from:
                  type: object
                  required: [postal]
                  properties:
                    postal:  { type: string, example: "H7S 1M9" }
                    country: { type: string, default: "CA", example: "CA" }
                to:
                  type: object
                  required: [postal]
                  properties:
                    postal:  { type: string, example: "H2L 2C4" }
                    country: { type: string, default: "CA", example: "CA" }
                    residential:
                      type: boolean
                      description: Carriers apply a residential surcharge; quote with the value you will buy with.
                      example: true
                packages:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: Parcels, in metric units. These exact parcels are what the returned `rate_id` can buy.
                  items:
                    type: object
                    required: [weight_kg, length_cm, width_cm, height_cm]
                    properties:
                      weight_kg: { type: number, example: 1.2 }
                      length_cm: { type: number, example: 25 }
                      width_cm:  { type: number, example: 18 }
                      height_cm: { type: number, example: 10 }
                      package_type:
                        type: string
                        enum: [package, envelope, courier_pak]
                        default: package
                        description: Box kind — priced, and signed into the rate_id. See ShipmentPackage.
                insured_value:
                  type: number
                  minimum: 0
                  maximum: 5000
                  description: Declared value to insure. Omit for an uninsured quote — see `GET /rates`.
                  example: 320
                signature_required:
                  type: boolean
                  default: false
                declared_value:
                  type: number
                  description: "**International only.** Customs value; without it a cross-border quote uses a $10 default."
                  example: 320
                hs_code:
                  type: string
                  description: "**International only.** Digits only, 6 minimum — shorter is dropped."
                  example: "8517120000"
                category:
                  type: string
                  description: "**International only.** Goods category, e.g. `mobiles`."
                  example: "mobiles"
                description:
                  type: string
                  example: "Refurbished phone"
            example:
              from: { postal: "H7S 1M9", country: "CA" }
              to:   { postal: "H2L 2C4", country: "CA", residential: true }
              packages:
                - { weight_kg: 1.2, length_cm: 25, width_cm: 18, height_cm: 10 }
                - { weight_kg: 2.4, length_cm: 30, width_cm: 20, height_cm: 12 }
              insured_value: 320
      responses:
        "200":
          description: Rates for the parcels supplied, cheapest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: list }
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Rate'
                  count: { type: integer, example: 12 }
        "400":
          description: Missing postal code, or a malformed/empty/oversized parcel list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /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:** This endpoint reads records. To buy a label, use
        > `POST /shipments`.

        **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'

    post:
      operationId: createShipment
      summary: Buy a shipping label
      description: |
        Buys a real carrier label and debits your 3PL Management wallet.

        **This spends money.** Fund your wallet (and ideally enable auto-reload) before
        pointing a production integration at it.

        ### The two-call flow

        1. `GET /rates` — quote the parcel. Each rate carries a `rate_id`.
        2. `POST /shipments` — send that `rate_id` back with the addresses.

        You never send a price. The `rate_id` is a signed token carrying the price you
        were quoted, and the label is charged at exactly that price — so a bug in your
        code cannot buy a label at the wrong amount.

        ### Idempotency

        Send an `Idempotency-Key` header (any stable UUID per order). A retry after a
        network failure returns the **original** label instead of buying a second one.
        Keys are remembered for 24 hours. This is strongly recommended: without it, a
        timeout you retry is a label you pay for twice.

        ### Labels are asynchronous

        Most carriers mint the PDF a few seconds after purchase, so `label_url` is
        often `null` in this response.

        **Subscribe to `shipment.label_ready`** — it fires once, per shipment, the
        moment the PDF exists, and carries the `label_url`. That is the cleanest way
        to drive "label ready → print it".

        If you would rather not run an endpoint, polling `GET /shipments/{id}` until
        `label_url` is set works too — every 5–10 s, giving up after a couple of
        minutes.

        `shipment.status_changed` and `shipment.delivered` follow the parcel once it
        is moving.

        ### Anything that costs extra must be in the quote

        `rate_id` fixes the price when it is issued, so every priced option is
        chosen at quote time and simply *confirmed* here: `insured_value`,
        `signature_required`, and the parcels themselves (dimensions **and**
        `package_type`). Confirming an option the quote did not price adds
        nothing and covers nothing; contradicting one is refused with a 400.

        ### The parcels must match the quote

        `rate_id` is signed against **the parcels as well as the price**, and this
        endpoint recomputes that fingerprint from your body. Send different parcels
        than you quoted and the request is refused with a 400 — never silently
        billed at the quoted price.

        A `rate_id` from `GET /rates` covers one parcel. For a multi-box shipment,
        quote all the boxes together with `POST /rates` and buy with the `rate_id`
        it returns. Order does not matter.

        ### Return address

        There is no stored "default" origin — `from` is whatever you send on each call.
        To route every parcel back to one warehouse regardless of which location packed
        it, send the same `from` block every time. Undeliverable parcels return to that
        address (`return_to_sender` is applied platform-wide).

        **Required scope:** `shipments:write`
        **Rate limit:** 5 req/min — this endpoint's own limiter allows 20/min, but the
        underlying purchase path caps every account at 5 label purchases per minute, so
        **5 is the ceiling you will actually hit.** Queue bulk work rather than bursting it.
      tags:
        - Shipments
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: Stable UUID for this purchase. Retries with the same key return the original shipment.
          example: "d1f8c2a4-5b6e-4c3a-9f10-7e2b8d4a6c11"
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rate_id, from, to, packages]
              properties:
                rate_id:
                  type: string
                  description: |
                    From `GET /rates`. Valid for 2 hours. Determines the carrier, the
                    service and the price.
                carrier:
                  type: string
                  description: Carrier label from the quote, recorded on the shipment.
                  example: "UPS"
                service:
                  type: string
                  description: Service label from the quote, recorded on the shipment.
                  example: "UPS Standard"
                from:
                  $ref: '#/components/schemas/ShipmentAddress'
                to:
                  $ref: '#/components/schemas/ShipmentAddress'
                packages:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: |
                    The parcels, in metric units, matching what you quoted.

                    **They must be the parcels the `rate_id` was quoted for.** The token
                    is signed against them, and a mismatch is refused with a 400 rather
                    than billed at the quoted price. Order does not matter.

                    A `rate_id` from `GET /rates` covers **one** parcel. To ship several
                    boxes on one label, quote them together with `POST /rates` first.
                  items:
                    $ref: '#/components/schemas/ShipmentPackage'
                handover:
                  type: string
                  enum: [dropoff, pickup]
                  default: dropoff
                  description: |
                    How the parcel reaches the carrier. Some carriers support only one;
                    the platform corrects an impossible choice rather than failing.

                    Send `pickup_date` with it to actually book a courier. Sec2083
                    documented `pickup` as recording intent only — that was true
                    until `pickup_date` existed here; **without a date it still
                    books nothing** (and charges nothing), so the parcel must be
                    dropped off.
                    This is a documented limitation, not a silent failure.
                pickup_date:
                  type: string
                  format: date
                  description: |
                    Date the carrier should collect, `YYYY-MM-DD`.

                    🔴 **`handover: "pickup"` books nothing without this.** The
                    pickup fee is only charged when a date is present, and it is
                    derived server-side ($2.50, or $3.50 for Canada Post) — you
                    never state a price.
                  example: "2026-09-02"
                pickup_time:
                  type: string
                  description: Requested window, free text, e.g. `13:00-17:00`.
                  example: "13:00-17:00"
                pickup_instructions:
                  type: string
                  maxLength: 300
                  example: "Ring the loading-dock buzzer"
                incoterm:
                  type: string
                  enum: [DDU, DDP]
                  default: DDU
                  description: |
                    Who pays duty on an international lane.

                    `DDU` (default) — the recipient is billed on delivery.
                    `DDP` — you prepay duty and tax; it is added to the wallet
                    debit and appears on the invoice.

                    ⚠️ `DDP` states an intent, not a guarantee. 3PL Management
                    re-quotes landed cost server-side (Easyship on US/CN lanes,
                    Zonos on Canada Post CA→US, a tariff engine elsewhere) and
                    ships **DDU** whenever it could not price or collect the
                    duty — so an unpriceable request degrades safely instead of
                    shipping a duty nobody paid. Ignored on domestic lanes.
                fta_claimed:
                  type: boolean
                  default: false
                  description: |
                    Claim a free-trade agreement (CUSMA/USMCA and equivalents).
                    Can take the duty on qualifying goods to zero. Only affects
                    a `DDP` quote; inert on `DDU`.
                eori:
                  type: string
                  maxLength: 40
                  description: EU EORI number, forwarded as a regulatory identifier.
                promo_code:
                  type: string
                  maxLength: 40
                  description: |
                    Re-validated server-side. An invalid or expired code is
                    ignored — it never fails the purchase.
                special_handling:
                  type: boolean
                  default: false
                  description: Recorded on the shipment. No carrier effect, no charge.
                ecommerce_store_id:
                  type: string
                  description: |
                    Storefront linkage, alongside `reference` (the order id) and
                    `ecommerce_platform`. Send all three so the label webhook can
                    push the fulfilment back to your store.
                ecommerce_platform:
                  type: string
                  example: "shopify"
                insurance:
                  type: boolean
                  default: false
                  description: |
                    Buy the insurance quoted on the selected rate.

                    The **coverage amount travels inside `rate_id`** — it is the
                    `insured_value` you quoted with, so there is nothing to send
                    here and nothing that can disagree with what you were
                    charged.

                    ⚠️ Against a rate quoted WITHOUT `insured_value` this still
                    covers nothing, because no premium was ever priced. Send
                    `insured_value` on the quote.
                signature_required:
                  type: boolean
                  default: false
                  description: |
                    Send the same value you quoted with — the surcharge has to be
                    in the price the `rate_id` fixed.
                reference:
                  type: string
                  maxLength: 120
                  description: |
                    Your own order identifier, stored on the shipment and echoed back.
                    Use it to reconcile a label against the order in your system.
                  example: "CS-10428"
            example:
              rate_id: "eyJjb3VyaWVyX2lkIjoiLi4uIn0.aBcDeF1234"
              carrier: "UPS"
              service: "UPS Standard"
              reference: "CS-10428"
              from:
                name: "CoinStation Warehouse"
                company: "CoinStation"
                address: "1500 Boulevard Saint-Martin O"
                city: "Laval"
                province: "QC"
                postal: "H7S 1M9"
                country: "CA"
                phone: "5145550142"
                email: "shipping@example.com"
              to:
                name: "Marie Tremblay"
                address: "425 Rue Sainte-Catherine E"
                city: "Montréal"
                province: "QC"
                postal: "H2L 2C4"
                country: "CA"
                phone: "5145550188"
                email: "marie@example.com"
                residential: true
              packages:
                - weight_kg: 1.2
                  length_cm: 25
                  width_cm: 18
                  height_cm: 10
                  description: "Refurbished phone"
                  declared_value: 320
      responses:
        "201":
          description: Label purchased. `label_url` may be null until the carrier mints the PDF.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:          { type: string, example: shipment }
                  id:              { type: string, example: "SHP-9K2MN7" }
                  status:          { type: string, example: "Label Created" }
                  tracking_number: { type: string, nullable: true, example: "1Z999AA10123456784" }
                  label_url:       { type: string, nullable: true, description: PDF label. Null until the carrier returns it. }
                  carrier:         { type: string, nullable: true, example: "UPS" }
                  service:         { type: string, nullable: true, example: "UPS Standard" }
                  total_charge:    { type: number, example: 46.48 }
                  currency:        { type: string, example: "CAD" }
                  reference:       { type: string, nullable: true, example: "CS-10428" }
                  note:            { type: string, description: Present only while the label is still being generated. }
        "400":
          description: |
            Invalid body — a missing address field, a malformed parcel, or a `rate_id`
            that is expired or unrecognised.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "402":
          description: Wallet balance too low to cover this label.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "403":
          description: |
            The key lacks `shipments:write`, the account is suspended, or the plan's
            monthly shipment limit is reached (`code: PLAN_LIMIT_EXCEEDED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /shipments/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: 3PL Management 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://www.my3plmanagement.com/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://www.my3plmanagement.com/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
  # ────────────────────────────────────────────────────────────────────────────

  /shipments/{id}/cancel:
    parameters:
      - name: id
        in: path
        required: true
        description: 3PL Management shipment ID
        example: "SHP-9K2MN7"
        schema:
          type: string

    post:
      operationId: cancelShipment
      summary: Cancel a shipment and refund it
      description: |
        Void a label you have already bought and get the money back.

        One call unwinds the whole purchase: the label is cancelled at whichever
        carrier issued it, the payment is refunded (to your wallet, or to the card
        that paid), and a linked storefront order is un-fulfilled.

        ### When it will refuse

        Cancelling is only possible while the parcel is still with you. Once the
        carrier has collected it — or once the payment has been captured — the
        window closes and this returns **409**. A shipment that has already been
        cancelled returns **200** with `already_cancelled: true`, so a retry after
        a network failure is safe and never double-refunds.

        **Required scope:** `shipments:write` — the same scope that bought the
        label. A key that can create a charge must be able to reverse it.
        **Rate limit:** 20 req/min
      tags:
        - Shipments
      responses:
        "200":
          description: Cancelled (or already was)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:            { type: string, example: "shipment_cancellation" }
                  shipment_id:       { type: string, example: "SHP-9K2MN7" }
                  cancelled:         { type: boolean, example: true }
                  already_cancelled:
                    type: boolean
                    description: The shipment was already cancelled before this call. Still a success.
                    example: false
                  refunded:          { type: boolean, example: true }
                  refund_amount:     { type: number, example: 46.48 }
                  message:           { type: string, example: "Shipment cancelled and refunded" }
        "403":
          description: The key lacks `shipments:write`, or the shipment belongs to another account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "404":
          description: No such shipment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "409":
          description: |
            Too late to cancel — the parcel is in transit, or the payment has been
            captured (`code: CANCEL_WINDOW_CLOSED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /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, 3PL Management
        > 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
  # ────────────────────────────────────────────────────────────────────────────

  /pickups:
    get:
      operationId: listPickups
      summary: List scheduled pickups
      description: |
        The pickups booked on this account, newest first.

        Book one by sending `pickup_date` with `handover: "pickup"` on
        `POST /shipments`; this is how you see it afterwards, confirm it, or notice
        the carrier has cancelled it.

        Statuses are refreshed against the carrier on every call, so a pickup that
        has since been confirmed or cancelled reads correctly here.

        **Required scope:** `pickups:read`
        **Rate limit:** 30 req/min
      tags:
        - Pickups
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by status, e.g. `scheduled`, `cancelled`.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20, maximum: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: A page of pickups
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: "list" }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:            { type: string }
                        carrier:       { type: string, example: "Canada Post" }
                        status:        { type: string, example: "scheduled" }
                        pickup_date:   { type: string, format: date, example: "2026-09-02" }
                        pickup_time:   { type: string, example: "13:00-17:00" }
                        pickup_reference_number:
                          type: string
                          description: The carrier's own reference — the number to quote if you call them.
                        reschedulable: { type: boolean }
                  count:    { type: integer }
                  limit:    { type: integer }
                  offset:   { type: integer }
                  has_more: { type: boolean }
        "403":
          description: The key lacks `pickups:read`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /pickups/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Pickup ID from `GET /pickups`
        schema: { type: string }

    delete:
      operationId: cancelPickup
      summary: Cancel a scheduled pickup
      description: |
        Cancels at the carrier first, then marks it cancelled here — so a failure
        never leaves a courier still coming for a pickup we consider cancelled.

        Idempotent: a pickup already cancelled returns **200** with
        `already_cancelled: true`.

        **Required scope:** `pickups:write` — it can refund the pickup fee, so it
        is a money scope and must be requested by name.
        **Rate limit:** 10 req/min
      tags:
        - Pickups
      responses:
        "200":
          description: Cancelled (or already was)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:            { type: string, example: "pickup_cancellation" }
                  pickup_id:         { type: string }
                  cancelled:         { type: boolean, example: true }
                  already_cancelled: { type: boolean, example: false }
        "403":
          description: The key lacks `pickups:write`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "404":
          description: No such pickup on this account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          $ref: '#/components/responses/TooManyRequests'
        "500":
          $ref: '#/components/responses/ServerError'

  /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/validate:
    post:
      operationId: validateAddress
      summary: Validate an address
      description: |
        Check an address before you buy a label for it.

        A bad address does not fail at purchase — it fails days later as a delivery
        exception, a return leg and a second label. This is the cheapest place to
        catch it.

        Returns a **confidence score with corrections** rather than a bare yes/no,
        because most bad addresses are nearly right: a transposed postal code, a
        missing unit, a province spelled out. Apply `corrections` automatically only
        when `valid` is `true`; otherwise show them to the customer.

        `verified_against_carrier` tells you which check ran. When `true`, the
        address was confirmed to exist against real carrier data. When `false`, only
        format rules were applied — so a `valid: true` there means "well-formed",
        not "deliverable".

        ### Validating several at once

        Send `addresses: [...]` (max 500) for a list. Batch runs **format rules
        only** — no carrier lookup — so use it to find the obviously broken entries,
        then validate the survivors one at a time as you ship them.

        **Required scope:** `addresses:read`
        **Rate limit:** 60 req/min
      tags:
        - Addresses
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  title: One address
                  required: [address]
                  properties:
                    name:     { type: string, example: "Marie Tremblay" }
                    address:
                      type: string
                      description: Street line. `address1` is accepted as an alias.
                      example: "1250 Rue University"
                    address2: { type: string, example: "Suite 400" }
                    city:     { type: string, example: "Montreal" }
                    province: { type: string, example: "QC" }
                    postal:   { type: string, example: "H3B 3A7" }
                    country:  { type: string, default: "CA", example: "CA" }
                    phone:    { type: string, example: "+15145551234" }
                - type: object
                  title: Several addresses
                  required: [addresses]
                  properties:
                    addresses:
                      type: array
                      minItems: 1
                      maxItems: 500
                      items:
                        type: object
            example:
              name: "Marie Tremblay"
              address: "1250 Rue University"
              city: "Montreal"
              province: "QC"
              postal: "H3B 3A7"
              country: "CA"
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:      { type: boolean, example: true }
                  confidence:
                    type: integer
                    description: 0-100. Capped at 50 when the carrier could not find the address.
                    example: 92
                  corrections:
                    type: object
                    description: Normalised field values. Safe to apply when `valid` is true.
                  warnings:
                    type: array
                    items: { type: string }
                  verified_against_carrier:
                    type: boolean
                    description: Whether real carrier data confirmed the address, or only format rules ran.
                    example: true
        "400":
          description: No address fields and no `addresses` array, or more than 500 entries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "403":
          description: The key lacks `addresses:read`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "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
# ────────────────────────────────────────────────────────────────────────────


# Sec2041 — this section is written against src/lib/webhook-dispatcher.ts and
# the dispatch sites that actually call it. It previously documented two events
# under names the platform has never emitted (a "status updated" spelling, and a
# standalone "exception" event that is really a status value) and omitted
# `return.created` entirely, so an integrator subscribing from these docs would
# have waited forever for a payload that was never coming.
#
# Subscribe at Dashboard → Developer → Webhooks. Every delivery is signed:
# verify `X-3PL-Signature` (HMAC-SHA256 of the raw body, `sha256=<hex>`)
# with a timing-safe comparison before trusting a payload.
#
# Delivery is at-least-once: 3 attempts (immediate, +2s, +8s), and an endpoint
# that fails 10 times in a row is automatically disabled. Deduplicate on the
# top-level `id`, and always return 2xx quickly.

webhooks:

  shipment.created:
    post:
      operationId: webhookShipmentCreated
      summary: Shipment created
      description: |
        Fired the moment a label purchase succeeds — including purchases made
        through `POST /api/v1/shipments`.

        `tracking_number` is usually present, but may be `null` on carriers that
        assign it asynchronously. `label_url` is **not** in this payload: fetch
        it with `GET /api/v1/shipments/{id}` once it is available.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              id: "b6f0c2a1-8d47-4e19-9f3c-2a5b7e0d1c48"
              event: "shipment.created"
              created_at: "2026-04-06T14:30:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                carrier: "UPS"
                status: "Pending"
                total_charge: 22.50
                currency: "CAD"
      responses:
        "200":
          description: Acknowledge receipt — return any 2xx to confirm delivery.

  shipment.status_changed:
    post:
      operationId: webhookStatusChanged
      summary: Shipment status changed
      description: |
        Fired when carrier tracking moves the shipment to **In Transit**,
        **Out for Delivery**, or **Exception**.

        There is no separate exception event — an exception arrives here with
        `status: "Exception"`. Delivery has its own event below.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              id: "0d9a4c73-1b62-4f80-a5e6-9c31d7b04f2a"
              event: "shipment.status_changed"
              created_at: "2026-04-07T08:45:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                status: "In Transit"
                carrier: "UPS"
                updated_at: "2026-04-07T08:45:00Z"
      responses:
        "200":
          description: Acknowledge receipt

  shipment.delivered:
    post:
      operationId: webhookDelivered
      summary: Shipment delivered
      description: |
        Fired when the carrier reports the parcel as delivered. Same payload
        shape as `shipment.status_changed`, with `status: "Delivered"`.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              id: "3f5b81e0-6c24-4a97-b1d8-70e2af9c5613"
              event: "shipment.delivered"
              created_at: "2026-04-08T11:20:00Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                status: "Delivered"
                carrier: "UPS"
                updated_at: "2026-04-08T11:20:00Z"
      responses:
        "200":
          description: Acknowledge receipt

  shipment.label_ready:
    post:
      operationId: webhookLabelReady
      summary: Shipping label ready
      description: |
        Fired once the carrier has minted the label PDF and `label_url` is
        available. Most carriers take a few seconds after purchase, so this is
        normally the second event you receive, after `shipment.created`.

        **This is the event to build a fulfilment flow on** — it is the moment a
        label can actually be printed. Subscribing means you do not have to poll
        `GET /shipments/{id}`, though polling remains a valid fallback.

        Delivered **exactly once per shipment**, claimed atomically: the label
        pipeline runs repeatedly by design (the carrier redelivers, and a
        reconcile endpoint, an admin repair action and a periodic sync all reuse
        it), and none of those re-fire this event.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              id: "c1e7d904-53fa-4b26-8e7d-0f4a6b219c85"
              event: "shipment.label_ready"
              created_at: "2026-04-06T14:31:05Z"
              data:
                shipment_id: "SHP-9K2MN7"
                tracking_number: "1Z999AA10123456784"
                label_url: "https://www.my3plmanagement.com/api/labels/proxy?shipment_id=SHP-9K2MN7"
      responses:
        "200":
          description: Acknowledge receipt

  return.created:
    post:
      operationId: webhookReturnCreated
      summary: "Return created — NOT YET EMITTED"
      description: |
        > ⚠️ **Subscribable, but nothing dispatches it yet.** Do not build on it —
        > a subscriber waits indefinitely. Read returns from the dashboard for now.
      tags:
        - Webhooks
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayload'
            example:
              id: "8a2c6f31-90b4-4de7-a05f-3b1e8c7d2049"
              event: "return.created"
              created_at: "2026-04-09T16:05:00Z"
              data:
                return_id: "RET-4Q8XZ2"
                shipment_id: "SHP-9K2MN7"
      responses:
        "200":
          description: Acknowledge receipt
