openapi: 3.1.0
info:
  title: Ship by Loop - External Shipping Provider API
  version: 1.0.0
  description: >-
    Endpoints an external shipping provider uses to fulfill shipments created
    within Ship by Loop.


    A provider calls these endpoints in response to the webhooks documented in
    [Ship by Loop shipment events](/webhook-reference/topics/ship-by-loop-shipment-webhook):


    1. `POST /ship-by-loop/shipments/{shipmentId}/services` — add available
       service(s) to the shipment, in response to `ship-by-loop.services.requested`.
    2. `POST /ship-by-loop/shipments/{shipmentId}/orders` — record order
       information for the services purchased, along with tracking and label
       details, in response to `ship-by-loop.purchase.requested`.
    3. `POST /ship-by-loop/shipments/{shipmentId}/errors` — report one or more
       problems the provider could not resolve on its own (e.g. the carrier
       rejected an address, or a package can't be serviced). Call this any
       time such an error occurs — it isn't tied to a specific webhook trigger.
servers:
  - url: https://api.loopreturns.com/api/{version}
    description: Production
    variables:
      version:
        default: latest
        description: >-
          `v2` is not a valid version segment — a request to `/api/v2/...`
          returns a 404. Use one of the values listed below instead.
        enum:
          - v1
          - latest
          - '2026-07'
          - '2026-10'
          - unstable
security:
  - oauth2: ["ship_by_loop:write"]
tags:
  - name: Ship by Loop
paths:
  /ship-by-loop/shipments/{shipmentId}/services:
    post:
      tags:
        - Ship by Loop
      operationId: addShipmentServices
      summary: Add available services to a shipment
      description: >-
        The response to the `ship-by-loop.services.requested` webhook.


        ### Required OAuth scope

        - `ship_by_loop:write`
      parameters:
        - $ref: '#/components/parameters/ShipmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddServicesRequest'
            examples:
              singleService:
                summary: One service covering one package
                value:
                  services:
                    - external_id: svc_ground
                      name: Ground Advantage
                      carrier: USPS
                      packages:
                        - 385e1705-ca07-41ba-b62e-49ac9065f2a4
              multipleServices:
                summary: Two services covering two packages
                value:
                  services:
                    - external_id: svc_ground
                      name: Ground Advantage
                      carrier: USPS
                      packages:
                        - bcc9cd48-ebdc-4d95-8116-fc58a323f3b7
                    - external_id: svc_express
                      name: Express 2-Day
                      carrier: UPS
                      packages:
                        - 37542eb3-1e35-4a0e-9c88-db3e3962091e
              sharedPackage:
                summary: Two services offered for the same package (shopper carrier choice)
                value:
                  services:
                    - external_id: svc_ground
                      name: Ground Advantage
                      carrier: USPS
                      packages:
                        - 6f2b6a34-df3c-4a1b-9e3e-6a7b6a1c2d3e
                    - external_id: svc_express
                      name: Express 2-Day
                      carrier: UPS
                      packages:
                        - 6f2b6a34-df3c-4a1b-9e3e-6a7b6a1c2d3e
              oneServiceAllPackages:
                summary: One service covering every package in the shipment
                value:
                  services:
                    - external_id: svc_ground
                      name: Ground Advantage
                      carrier: USPS
                      packages:
                        - 6f2b6a34-df3c-4a1b-9e3e-6a7b6a1c2d3e
                        - 8a1c2d3e-6f2b-4a34-9e3e-df3c6a7b6a1c
      responses:
        '204':
          $ref: '#/components/responses/NoContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/ShipmentNotFound'
        '422':
          $ref: '#/components/responses/AddServicesUnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /ship-by-loop/shipments/{shipmentId}/orders:
    post:
      tags:
        - Ship by Loop
      operationId: recordShipmentOrders
      summary: Record orders on a shipment
      description: >-
        The response to the `ship-by-loop.purchase.requested` webhook.


        ### Required OAuth scope

        - `ship_by_loop:write`
      parameters:
        - $ref: '#/components/parameters/ShipmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordOrdersRequest'
            examples:
              withLabel:
                summary: Order with a printable label
                value:
                  orders:
                    - package_id: 2f0c48ef-1bdc-462e-ac36-8ad435b1b4cd
                      external_id: ord_123
                      tracking_number: 1Z999
                      tracking_url: https://partner.example/track/1Z999
                      label_url: https://partner.example/labels/1.pdf
                      qr_code_url: https://partner.example/qr/1.png
              labelless:
                summary: QR code only, no printable label
                description: >-
                  At least one of `label_url` or `qr_code_url` is required —
                  omitting both returns a 422 ("Package {package_id} must be
                  assigned a QR Code or a Label"). This example provides only
                  the QR code.
                value:
                  orders:
                    - package_id: 1d81b2ab-371b-4310-a023-c51ad643bd8f
                      external_id: ord_123
                      tracking_number: 1Z999
                      tracking_url: null
                      label_url: null
                      qr_code_url: https://partner.example/qr/1.png
      responses:
        '204':
          $ref: '#/components/responses/NoContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/ShipmentNotFound'
        '422':
          $ref: '#/components/responses/RecordOrdersUnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /ship-by-loop/shipments/{shipmentId}/errors:
    post:
      tags:
        - Ship by Loop
      operationId: recordShipmentErrors
      summary: Report errors on a shipment
      description: >-
        Report one or more problems the provider could not resolve on its
        own. Each error is scoped to a package, and may optionally be scoped
        further to a single item within that package.


        Reporting an error is purely a record — it does not change the
        shipment's state or the shopper-facing flow, and it does not block
        you from still calling `/services` or `/orders` for the same
        package afterward. Errors are not deduplicated, so retrying the
        same call records a duplicate entry each time.


        ### Required OAuth scope

        - `ship_by_loop:write`
      parameters:
        - $ref: '#/components/parameters/ShipmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordErrorsRequest'
            examples:
              packageLevel:
                summary: An error that applies to the whole package
                value:
                  errors:
                    - message: Carrier rejected the destination address
                      package_id: 385e1705-ca07-41ba-b62e-49ac9065f2a4
              itemLevel:
                summary: An error scoped to a single item within a package
                value:
                  errors:
                    - message: Item requires a customs declaration we don't support
                      package_id: 385e1705-ca07-41ba-b62e-49ac9065f2a4
                      item_id: 0565b5e0-f2dd-469e-9ad7-f913fa689fc4
              multiple:
                summary: Multiple errors in one call
                value:
                  errors:
                    - message: Carrier rejected the destination address
                      package_id: bcc9cd48-ebdc-4d95-8116-fc58a323f3b7
                    - message: No service available for this package's weight
                      package_id: 37542eb3-1e35-4a0e-9c88-db3e3962091e
              samePackageTwice:
                summary: An item-level error and a package-level error on the same package
                value:
                  errors:
                    - message: Item requires a customs declaration we don't support
                      package_id: 385e1705-ca07-41ba-b62e-49ac9065f2a4
                      item_id: 0565b5e0-f2dd-469e-9ad7-f913fa689fc4
                    - message: Carrier rejected the destination address
                      package_id: 385e1705-ca07-41ba-b62e-49ac9065f2a4
      responses:
        '204':
          $ref: '#/components/responses/NoContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/ShipmentNotFound'
        '422':
          $ref: '#/components/responses/RecordErrorsUnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://oauth.loopreturns.com/authorize
          tokenUrl: https://oauth.loopreturns.com/oauth/token
          scopes:
            "ship_by_loop:write": Submit services, orders, and errors for Ship by Loop shipments
  parameters:
    ShipmentId:
      name: shipmentId
      in: path
      required: true
      description: The id of the Shipment
      schema:
        type: string
        examples:
          - ff8a4470-41e0-4cc3-9bfe-44564f4286b7
  schemas:
    AddServicesRequest:
      type: object
      additionalProperties: false
      required:
        - services
      properties:
        services:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/ExternalService'
    ExternalService:
      type: object
      additionalProperties: false
      required:
        - external_id
        - name
        - carrier
        - packages
      properties:
        external_id:
          type: string
          maxLength: 64
          description: The provider's identifier for the service
          examples:
            - svc_ground
        name:
          type: string
          maxLength: 100
          description: The human-readable service name
          examples:
            - Ground Advantage
        carrier:
          type: string
          maxLength: 100
          description: The carrier fulfilling the service
          examples:
            - USPS
        packages:
          type: array
          minItems: 1
          description: >-
            The ids of the packages this service covers. The same package id
            may appear under more than one service (e.g. offering the shopper
            a carrier choice), and a single service may list every package id
            in the shipment.
          items:
            type: string
            examples:
              - 0565b5e0-f2dd-469e-9ad7-f913fa689fc4
    RecordOrdersRequest:
      type: object
      additionalProperties: false
      required:
        - orders
      properties:
        orders:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/ExternalOrder'
    ExternalOrder:
      type: object
      additionalProperties: false
      required:
        - package_id
        - external_id
        - tracking_number
      properties:
        package_id:
          type: string
          examples:
            - 900a8671-35ad-4743-9b6c-2540d3bb1faf
        external_id:
          type: string
          maxLength: 64
          description: The provider's identifier for the purchase order
          examples:
            - ord_123
        tracking_number:
          type: string
          description: The tracking number for the package
          examples:
            - 1Z999
        tracking_url:
          type: [string, 'null']
          format: uri
          description: The public tracking page for the package
          examples:
            - https://partner.example/track/1Z999
        label_url:
          type: [string, 'null']
          format: uri
          description: The printable shipping label
          examples:
            - https://partner.example/labels/1.pdf
        qr_code_url:
          type: [string, 'null']
          format: uri
          description: The QR code
          examples:
            - https://partner.example/qr/1.png
    RecordErrorsRequest:
      type: object
      additionalProperties: false
      required:
        - errors
      properties:
        errors:
          type: array
          minItems: 1
          description: >-
            The same `package_id` may appear in more than one entry — for
            example, an item-level error and a separate package-level error
            on the same package.
          items:
            $ref: '#/components/schemas/ExternalError'
    ExternalError:
      type: object
      additionalProperties: false
      required:
        - message
        - package_id
      properties:
        message:
          type: string
          maxLength: 500
          description: A human-readable description of the problem
          examples:
            - Carrier rejected the destination address
        package_id:
          type: string
          description: The package this error applies to
          examples:
            - 385e1705-ca07-41ba-b62e-49ac9065f2a4
        item_id:
          type: [string, 'null']
          description: >-
            Scopes the error to a single item within the package. Omit (or
            send `null`) for an error that applies to the whole package.
          examples:
            - 0565b5e0-f2dd-469e-9ad7-f913fa689fc4
    AuthError:
      type: object
      description: >-
        Returned when the access token or API key itself is missing, invalid,
        expired, or unrecognized.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              examples:
                - '401'
            http_code:
              type: string
            message:
              type: string
              examples:
                - Unauthorized.
    ScopeError:
      type: object
      description: >-
        Returned when the credentials are valid but lack the
        `ship_by_loop:write` scope for this endpoint.
      required:
        - errors
      properties:
        errors:
          type: string
          examples:
            - Unauthorized.
    ValidationError:
      type: object
      description: Request body failed validation.
      properties:
        message:
          type: string
          examples:
            - The services field is required
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          examples:
            - services:
                - The services field is required
    ShippingError:
      type: object
      properties:
        errors:
          type: array
          items:
            type: string
            examples:
              - >-
                Package 27edf77e-21f5-4ae7-8d4c-b32901d51226 does not belong to
                shipment faf035fd-9064-4b75-94a2-a6aeac7eac31
  responses:
    NoContent:
      description: "The request was processed successfully"
    Unauthorized:
      description: >-
        The access token is missing, invalid, or expired, or it lacks the
        `ship_by_loop:write` scope. The response body differs depending on
        which of those is the case (see the two examples below).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/ScopeError'
          examples:
            invalidCredentials:
              summary: Missing, invalid, or expired credentials
              value:
                error:
                  code: '401'
                  http_code: GEN-UNAUTHORIZED
                  message: Unauthorized.
            missingScope:
              summary: Valid credentials, missing the required scope
              value:
                errors: Unauthorized.
    Forbidden:
      description: "The caller is not permitted to modify this shipment. The response body is empty."
    ShipmentNotFound:
      description: No shipment matches `shipmentId`. The response body is empty.
    AddServicesUnprocessableEntity:
      description: "The request could not be processed"
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/ShippingError'
          examples:
            validationFailure:
              summary: Payload failed validation
              value:
                errors:
                  services:
                    - The services field is required
            domainFailure:
              summary: Business rule violation
              value:
                errors:
                  - >-
                    Package a25cc361-6d65-4f8b-a78b-15b0657bc35a does not
                    belong to shipment 7cdfc082-6dc7-4e9d-8f77-c4250b9cd573
    RecordOrdersUnprocessableEntity:
      description: "The request could not be processed"
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/ShippingError'
          examples:
            validationFailure:
              summary: Payload failed validation
              value:
                errors:
                  orders:
                    - The orders field is required
            domainFailure:
              summary: Business rule violation
              value:
                errors:
                  - >-
                    Package a25cc361-6d65-4f8b-a78b-15b0657bc35a does not
                    belong to shipment 7cdfc082-6dc7-4e9d-8f77-c4250b9cd573
            missingLabelOrQrCode:
              summary: Neither a label nor a QR code was provided
              value:
                errors:
                  - >-
                    Package a25cc361-6d65-4f8b-a78b-15b0657bc35a must be
                    assigned a QR Code or a Label
    RecordErrorsUnprocessableEntity:
      description: "The request could not be processed"
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/ShippingError'
          examples:
            validationFailure:
              summary: Payload failed validation
              value:
                errors:
                  errors:
                    - The errors field is required
            domainFailure:
              summary: Business rule violation
              value:
                errors:
                  - >-
                    Package a25cc361-6d65-4f8b-a78b-15b0657bc35a does not
                    belong to shipment 7cdfc082-6dc7-4e9d-8f77-c4250b9cd573
            itemNotInPackage:
              summary: item_id doesn't belong to the given package_id
              value:
                errors:
                  - >-
                    Item 0565b5e0-f2dd-469e-9ad7-f913fa689fc4 does not belong
                    to package 385e1705-ca07-41ba-b62e-49ac9065f2a4
    InternalServerError:
      description: "An error occurred. The response body is empty."
