openapi: 3.0.3
info:
  title: Uniship API
  version: "1.0.0"
  description: |
    API REST pour demander des prix, créer des envois et suivre vos colis Uniship depuis votre ERP, votre boutique ou votre logiciel.

    **Authentification** — créez une clé dans le portail (Mon profil > clés d'API) et envoyez-la dans l'en-tête `Authorization: Bearer usk_live_…`.
    Une clé `usk_test_…` renvoie de vrais prix mais ne crée aucun envoi réel et ne facture rien : utilisez-la pour développer.

    **Ouverture** — les envois réels (clé `live`) sont possibles dès l'ouverture d'Uniship (début décembre 2026).
    Avant cela, la création d'envoi avec une clé live répond `409 portal_closed`.

    **Limites** — 120 requêtes/minute en lecture, 30/minute pour `POST /offers`, 60/minute pour `POST /shipments`.
    En cas de dépassement : `429` et en-tête `Retry-After`.

    **Idempotence** — envoyez `Idempotency-Key: <valeur unique>` sur `POST /shipments` : un nouvel essai avec la même valeur renvoie le même résultat sans créer un second envoi (valable 48 h).
servers:
  - url: https://portail.uniship.be/api/v1
security:
  - bearer: []
paths:
  /ping:
    get:
      summary: Tester la clé
      responses:
        "200": { description: Clé valide, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, mode: { type: string, enum: [live, test] } } } } } }
  /offers:
    post:
      summary: Demander des prix
      description: Renvoie les offres (transporteur, service, prix HTVA, délai). Chaque offre est valable 15 minutes et ne peut servir qu'à un seul envoi.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferRequest" }
            example:
              originCountry: BE
              originZip: "7170"
              destCountry: FR
              destZip: "75001"
              kind: parcel
              parcels: [ { weight: 5, length: 40, width: 30, height: 20 } ]
      responses:
        "200": { description: Offres, content: { application/json: { schema: { $ref: "#/components/schemas/OfferResponse" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /shipments:
    post:
      summary: Créer un envoi à partir d'une offre
      parameters:
        - { name: Idempotency-Key, in: header, required: false, schema: { type: string, maxLength: 120 } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateShipment" }
      responses:
        "201": { description: Envoi créé, content: { application/json: { schema: { $ref: "#/components/schemas/Shipment" } } } }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "410": { description: Offre expirée, redemandez les prix, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } } }
    get:
      summary: Lister vos envois (les plus récents d'abord)
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: status, in: query, schema: { type: string, enum: [pending, label_generated, picked_up, in_transit, out_for_delivery, delivered, exception, cancelled] } }
        - { name: reference, in: query, schema: { type: string }, description: Votre numéro de commande }
        - { name: startAfter, in: query, schema: { type: string }, description: Valeur nextStartAfter de la page précédente }
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Shipment" } }
                  nextStartAfter: { type: string, nullable: true }
  /shipments/{id}:
    get:
      summary: Lire un envoi et son suivi
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: Envoi, content: { application/json: { schema: { $ref: "#/components/schemas/Shipment" } } } }
        "404": { $ref: "#/components/responses/Error" }
  /shipments/{id}/label:
    get:
      summary: Télécharger l'étiquette
      description: JSON avec les PDF en base64, ou le PDF directement avec `?format=pdf&piece=1`. Répond 409 tant que l'étiquette n'est pas prête.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: format, in: query, schema: { type: string, enum: [json, pdf] } }
        - { name: piece, in: query, schema: { type: integer, minimum: 1 } }
      responses:
        "200": { description: Étiquette(s) }
        "409": { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    bearer: { type: http, scheme: bearer, description: "Clé d'API Uniship (usk_live_… ou usk_test_…)" }
  responses:
    Error: { description: Erreur, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorBody" } } } }
  schemas:
    ErrorBody:
      type: object
      properties:
        error: { type: object, properties: { code: { type: string }, message: { type: string } } }
    Parcel:
      type: object
      required: [weight, length, width, height]
      properties: { weight: { type: number, description: kg }, length: { type: number, description: cm }, width: { type: number }, height: { type: number } }
    OfferRequest:
      type: object
      required: [originCountry, destCountry, parcels]
      properties:
        originCountry: { type: string, description: "Code pays à 2 lettres (BE, FR…)" }
        originZip: { type: string }
        originCity: { type: string }
        destCountry: { type: string }
        destZip: { type: string }
        destCity: { type: string }
        kind: { type: string, enum: [parcel, pallet], default: parcel }
        shipDate: { type: string, format: date }
        residential: { type: boolean }
        parcels: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/Parcel" } }
    OfferResponse:
      type: object
      properties:
        mode: { type: string }
        expiresInMinutes: { type: number }
        notices: { type: array, items: { type: string } }
        offers:
          type: array
          items:
            type: object
            properties:
              offerId: { type: string }
              label: { type: string }
              carrier: { type: string }
              tags: { type: array, items: { type: string } }
              price: { type: number, description: "EUR HTVA, carburant inclus" }
              currency: { type: string }
              transitMin: { type: integer }
              transitMax: { type: integer }
              deliveryDate: { type: string, nullable: true }
              billableKg: { type: number }
              fees: { type: array, items: { type: object } }
              options: { type: array, nullable: true, items: { type: object, properties: { id: { type: string }, label: { type: string }, price: { type: number } } } }
    Address:
      type: object
      properties:
        name: { type: string }
        company: { type: string }
        contact: { type: string }
        address: { type: string }
        city: { type: string }
        zip: { type: string }
        country: { type: string }
        state: { type: string, description: "Code à 2 lettres pour US, CA, MX, AU, BR, IN" }
        phone: { type: string }
        email: { type: string }
    CreateShipment:
      type: object
      required: [offerId, sender, recipient]
      properties:
        offerId: { type: string }
        optionId: { type: string, description: "Option de livraison proposée avec l'offre (PostNL), sinon none" }
        reference: { type: string, maxLength: 60, description: Votre numéro de commande }
        shipDate: { type: string, format: date }
        notes: { type: string, maxLength: 1000 }
        insuranceRequested: { type: boolean }
        sender: { $ref: "#/components/schemas/Address" }
        recipient: { $ref: "#/components/schemas/Address" }
        customs:
          type: object
          description: Obligatoire hors Union européenne
          properties:
            purpose: { type: string, enum: [sale, gift, sample, return, personal, notsold] }
            senderTaxId: { type: string }
            recipientTaxId: { type: string }
            items:
              type: array
              items: { type: object, properties: { description: { type: string }, qty: { type: integer }, unitValue: { type: number }, origin: { type: string }, hs: { type: string } } }
    Shipment:
      type: object
      properties:
        id: { type: string }
        reference: { type: string, nullable: true }
        status: { type: string, enum: [pending, label_generated, picked_up, in_transit, out_for_delivery, delivered, exception, cancelled] }
        carrier: { type: string }
        service: { type: string }
        price: { type: number }
        currency: { type: string }
        trackingNumber: { type: string, nullable: true }
        trackingNumbers: { type: array, items: { type: string } }
        labelStatus: { type: string, nullable: true }
        expectedDelivery: { type: string, nullable: true }
        sender: { $ref: "#/components/schemas/Address" }
        recipient: { $ref: "#/components/schemas/Address" }
        createdAt: { type: string, format: date-time }
        tracking: { type: object, nullable: true }
