openapi: 3.1.0
info:
  title: Nucleo Returns Portal API
  version: v1
  summary: Let shoppers find their order and create a return, exchange or store-credit request.
  description: |
    The public API behind the Nucleo-hosted returns page (`https://app.nucleoplatform.com/returns/{slug}`).
    Use it to build your own returns experience inside your storefront or app: identify the order with
    its number plus the shopper's email or postcode, show what can be returned and how, create the return
    (refund, exchange with live stock, or store credit; carrier label or in-store drop-off; up to three
    photos), then follow it on a status page and download the return label.

    There are no API keys. The shopper proves ownership with order number + email or postcode and gets a
    short-lived encrypted **session token** (`X-Return-Session`, one hour, one order). Each created return
    gets a long random **return token** that opens its status page and label without any session.

    Every rule applied here (return window, non-returnable items, reasons, fees, exchange options) is the
    merchant's return policy, the same one used by customer service and the warehouse.
  contact:
    name: Nucleo support
    url: https://nucleoplatform.com
x-nucleo:
  product: returns
  module: commerce
  audience: [storefront]
  stability: beta
  format: mixed
  order: 31
servers:
  - url: https://api-commerce.nucleoplatform.com/api/oms/v1/public/returns/{slug}
    description: Production
    variables:
      slug:
        default: acme
        description: |
          The store's returns-portal slug (`^[a-z0-9][a-z0-9-]{0,63}$`). It is the last segment of the
          portal link shown in **Settings › Orders › Returns and refunds › Return portal**
          (`https://app.nucleoplatform.com/returns/{slug}`).
tags:
  - name: Portal
    description: |
      Branding and rules shown before the shopper signs in.

      **Turning it on.** In Nucleo go to **Settings › Orders › Returns and refunds › Return portal**,
      switch the portal on and copy its link; the *Return policy* tab sets window, reasons, resolutions,
      fees and exclusions. While the portal is off, every endpoint that needs it answers `404` as if the
      store did not exist (return status pages and labels keep working).
  - name: Order lookup
    description: |
      Identify the order and open a one-hour session.

      **Building your own UI.** The API can be called from the browser (CORS allows any origin and the
      `X-Return-Session` header) or from your server. Rate limits are counted per caller IP **and** the first
      address in `X-Forwarded-For`: if you proxy calls through your own server, forward the shopper's IP in
      `X-Forwarded-For`, otherwise all your shoppers share one bucket. Responses for "order not found" and
      "wrong email/postcode" are identical on purpose.
  - name: Returns
    description: Create a return for the order of the current session.
  - name: Return status
    description: |
      Public status page and return label, addressed by the return token received at creation (also
      listed in `returns[].token` of the order view). Treat the token as a secret link: anyone who has it can
      see the return and download the label.
paths:
  /:
    get:
      operationId: getReturnsPortal
      summary: Get portal branding and rules
      tags: [Portal]
      security: []
      description: |
        Brand (name, logo, accent colour, support contacts), languages, how the shopper can identify the
        order (`verify_with`), the default return window, the return fee and which resolutions are offered.
        Use it to render the sign-in step.

        Rate limit: 60 requests per minute per client (bucket shared with `/order`, `/r/{token}` and
        `/r/{token}/label`), and 1,800 per minute for the whole store.
      x-rate-limit:
        limit: 60
        window: 1m
        scope: per IP
      x-rate-limit-additional:
        - limit: 1800
          window: 1m
          scope: per store
      responses:
        '200':
          description: Portal information.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/PortalInfo'
              examples:
                portal:
                  value:
                    data:
                      brand:
                        name: Acme Apparel
                        logo_url: https://cdn.acme.example/logo.svg
                        accent_color: '#0F766E'
                        support_email: help@example.com
                        support_url: https://shop.acme.example/pages/contact
                        slug: acme
                      languages: [it, en, de, fr, es]
                      default_language: en
                      verify_with: [email, zip]
                      window_days: 30
                      fee: 4.9
                      resolutions:
                        refund: true
                        exchange: true
                        store_credit: true
        '404':
          $ref: '#/components/responses/PortalNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /lookup:
    post:
      operationId: lookupReturnOrder
      summary: Find an order and open a session
      tags: [Order lookup]
      security: []
      description: |
        Finds the order by number (with or without the leading `#`, case-insensitive) and checks the
        verifier against the order's email (case-insensitive) or shipping postcode (spaces and hyphens
        ignored), as allowed by the policy's `verify_with`. Exchange orders created by Nucleo cannot be
        looked up.

        On success returns a session token valid for one hour for this order only, the resolved language and
        the full order view (what can be returned, reasons, resolutions, methods, fees, previous returns).

        **Language** is the first of: `language` in the body, the order's language, the policy default —
        each only if enabled in the policy — otherwise `en`.

        **Rate limits**
        - 10 lookups per minute per client (300 per minute for the whole store);
        - 5 failed attempts per order number every 15 minutes: after that the order number is locked
          (`429 too_many_attempts`) until the window expires. A successful lookup resets the counter.

        Validation errors (`422`) do not count towards the limits.
      x-rate-limit:
        limit: 10
        window: 1m
        scope: per IP
      x-rate-limit-additional:
        - limit: 300
          window: 1m
          scope: per store
        - limit: 5
          window: 15m
          scope: per order number
          note: Failed attempts only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LookupRequest'
            examples:
              byEmail:
                value:
                  order: '#1042'
                  verifier: giulia.rossi@example.com
                  language: it
              byPostcode:
                value:
                  order: '1042'
                  verifier: '20121'
      responses:
        '200':
          description: Order found; session opened.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - type: object
                        required: [session, expires_in, language]
                        properties:
                          session:
                            type: string
                            description: Opaque session token. Send it as `X-Return-Session` to `/order` and `/returns`.
                          expires_in:
                            type: integer
                            description: Seconds until the session expires (always 3600).
                            example: 3600
                          language:
                            $ref: '#/components/schemas/Language'
                      - $ref: '#/components/schemas/OrderView'
              examples:
                found:
                  $ref: '#/components/examples/LookupFound'
        '404':
          description: |
            `not_found`: no such order, or the email/postcode does not match (indistinguishable on purpose).
            Also returned, with an empty `message`, when the store or its portal does not exist or is off.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
              examples:
                notFound:
                  value:
                    message: not_found
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          description: Too many lookups from this client, or too many failed attempts on this order number.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RateLimited'
              examples:
                client:
                  summary: Client or store limit
                  value:
                    message: too_many_requests
                    retry_after: 42
                orderLocked:
                  summary: Order number locked after 5 failed attempts
                  value:
                    message: too_many_attempts
                    retry_after: 873
  /order:
    get:
      operationId: getReturnOrder
      summary: Get the order of the current session
      tags: [Order lookup]
      security:
        - returnSession: []
      description: |
        The same order view returned by `/lookup`, recomputed now (returnable quantities, exchange stock,
        previous returns), without opening a new session. Use it to refresh the form or switch language.

        Rate limit: 60 requests per minute per client (shared portal bucket), 1,800 per minute per store.
      x-rate-limit:
        limit: 60
        window: 1m
        scope: per IP
      parameters:
        - name: language
          in: query
          required: false
          description: Preferred language; used only if enabled in the policy (see `/lookup`).
          schema:
            $ref: '#/components/schemas/Language'
          example: it
      responses:
        '200':
          description: The order view.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - type: object
                        required: [language]
                        properties:
                          language:
                            $ref: '#/components/schemas/Language'
                      - $ref: '#/components/schemas/OrderView'
        '401':
          $ref: '#/components/responses/SessionExpired'
        '404':
          $ref: '#/components/responses/PortalNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /returns:
    post:
      operationId: createReturn
      summary: Create a return
      tags: [Returns]
      security:
        - returnSession: []
      description: |
        Creates a return for the session's order. Send either JSON or `multipart/form-data` (needed for
        photos). The return itself goes in `payload`: a JSON object (JSON body) or a JSON-encoded string
        (multipart). A JSON body without the `payload` wrapper is accepted too.

        **Checks**, in order (the first failure is returned as `422` with a code in `message`):
        - the order as a whole can be returned (`cancelled`, `not_shipped`, `window_closed`, `limit_reached`);
        - `method` is offered (`invalid_method`) and, for `store_dropoff`, `store_id` is one of the listed
          stores (`invalid_store`);
        - for each line: still returnable in that quantity (`line_not_returnable`), `reason` is an active
          reason (`invalid_reason`), `resolution` is enabled (`invalid_resolution`), and for `exchange` the
          chosen `exchange_barcode` is among the line's options and in stock for the quantity
          (`exchange_unavailable`);
        - at least one valid line (`no_lines`) — lines with an unknown `id` or a quantity of 0 are skipped;
        - at least one photo when a chosen reason requires it (`photo_required`).

        **What happens**
        - A return `{order}-R{n}` is created in status `requested`, with the return fee computed by the
          policy (free for exchange/store credit, in-store drop-off or "our fault" reasons if so configured).
        - The return is routed to the location that will receive it.
        - Exchanges: a replacement order `{order}-EX{n}` is created and the new size/colour is reserved. In
          *standard* mode it ships when the return arrives intact; in *advance* mode (if the policy and the
          customer's history allow it) it ships straight away. If the replacement costs more and the policy
          says so, the shopper is asked to pay the difference before it ships.
        - `carrier_label`: a prepaid return label is generated with the merchant's return carrier. If label
          creation fails the return is still created (`has_label: false` on the status page).
        - `store_dropoff`: a drop-off code and QR code are issued for the store.

        Not idempotent: each successful call creates a new return.

        Rate limit: 10 requests per minute per client, 300 per minute per store.
      x-rate-limit:
        limit: 10
        window: 1m
        scope: per IP
      x-rate-limit-additional:
        - limit: 300
          window: 1m
          scope: per store
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payload]
              properties:
                payload:
                  $ref: '#/components/schemas/CreateReturn'
            examples:
              refundWithLabel:
                summary: Refund, prepaid carrier label
                value:
                  payload:
                    method: carrier_label
                    language: it
                    lines:
                      - id: 9d3f6b1e-2a4c-4e8b-a1f0-6c2d8e4b7a10
                        quantity: 1
                        reason: too_small
                        resolution: refund
                        comment: Runs a bit tight on the shoulders
              exchangeInStore:
                summary: Exchange for another size, dropped off in store
                value:
                  payload:
                    method: store_dropoff
                    store_id: 4b8e2c71-0f3a-4d59-9e6b-2a7c1d8f5e03
                    lines:
                      - id: 9d3f6b1e-2a4c-4e8b-a1f0-6c2d8e4b7a10
                        quantity: 1
                        reason: too_small
                        resolution: exchange
                        exchange_barcode: '8001234567892'
          multipart/form-data:
            schema:
              type: object
              required: [payload]
              properties:
                payload:
                  type: string
                  description: The `CreateReturn` object, JSON-encoded.
                  example: '{"method":"carrier_label","lines":[{"id":"9d3f6b1e-2a4c-4e8b-a1f0-6c2d8e4b7a10","quantity":1,"reason":"defective","resolution":"refund"}]}'
                photos:
                  type: array
                  maxItems: 3
                  description: Up to 3 images (jpg, jpeg, png, webp, heic), max 8 MB each. Send as `photos[]`.
                  items:
                    type: string
                    format: binary
            encoding:
              photos:
                contentType: image/jpeg, image/png, image/webp, image/heic
      responses:
        '201':
          description: Return created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [token, name]
                    properties:
                      token:
                        type: string
                        pattern: '^[A-Za-z0-9]{40}$'
                        description: Return token for `/r/{token}` and `/r/{token}/label`. Keep it; it is the only handle.
                      name:
                        type: string
                        description: Return number shown to the shopper.
              examples:
                created:
                  value:
                    data:
                      token: Xk2Lm9Qa7Rt4Vb1Nc8Wd3Ye6Zf0Gh5Jp2Ks7Lq4M
                      name: '#1042-R1'
        '401':
          $ref: '#/components/responses/SessionExpired'
        '404':
          $ref: '#/components/responses/PortalNotFound'
        '422':
          description: |
            Business rule failure (`message` is a code, see the description) or invalid photos (standard
            validation body with `errors`). `invalid_payload` when `payload` is not a JSON object.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Message'
                  - $ref: '#/components/schemas/ValidationError'
              examples:
                exchangeUnavailable:
                  value:
                    message: exchange_unavailable
                photoRequired:
                  value:
                    message: photo_required
                windowClosed:
                  value:
                    message: window_closed
                badPhoto:
                  value:
                    message: 'The photos.0 field must be a file of type: jpg, jpeg, png, webp, heic.'
                    errors:
                      photos.0:
                        - 'The photos.0 field must be a file of type: jpg, jpeg, png, webp, heic.'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /r/{token}:
    parameters:
      - $ref: '#/components/parameters/ReturnToken'
    get:
      operationId: getReturnStatus
      summary: Get a return's public status
      tags: [Return status]
      security: []
      description: |
        Everything the shopper needs after creating the return: status in plain words, method and
        instructions (store and drop-off code with QR code, or carrier and tracking number and whether a
        label is available), fee, refund or store-credit amount and refund state, the exchange order and any
        price difference, and the returned lines. Works even if the portal has since been switched off.

        Reading the status also refreshes the refund state from the sales channel when a refund is pending.

        Rate limit: 60 requests per minute per client (shared portal bucket), 1,800 per minute per store.
      x-rate-limit:
        limit: 60
        window: 1m
        scope: per IP
      responses:
        '200':
          description: Return status.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/ReturnStatus'
              examples:
                carrier:
                  $ref: '#/components/examples/StatusCarrier'
                storeExchange:
                  $ref: '#/components/examples/StatusStoreExchange'
        '404':
          description: Unknown store, malformed or unknown token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
              examples:
                notFound:
                  value:
                    message: ''
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /r/{token}/label:
    parameters:
      - $ref: '#/components/parameters/ReturnToken'
    get:
      operationId: downloadReturnLabel
      summary: Download the return label
      tags: [Return status]
      security: []
      description: |
        The prepaid return label as a file download: PDF, or ZPL (plain text) for carriers configured
        to print thermal labels. Only for `carrier_label` returns whose label was generated
        (`has_label: true`). Sent with `Cache-Control: private, no-store`.

        Rate limit: 60 requests per minute per client (shared portal bucket), 1,800 per minute per store.
      x-rate-limit:
        limit: 60
        window: 1m
        scope: per IP
      responses:
        '200':
          description: The label.
          headers:
            Content-Disposition:
              description: Attachment named `{return name}-label.pdf` or `.zpl`.
              schema:
                type: string
              example: attachment; filename="#1042-R1-label.pdf"
            Cache-Control:
              schema:
                type: string
              example: private, no-store
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            text/plain:
              schema:
                type: string
                description: ZPL label.
        '404':
          description: Unknown store or token, or no label for this return.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
              examples:
                noLabel:
                  value:
                    message: ''
        '429':
          $ref: '#/components/responses/TooManyRequests'
components:
  securitySchemes:
    returnSession:
      type: apiKey
      in: header
      name: X-Return-Session
      description: |
        Session token returned by `POST /lookup` (`data.session`). Encrypted and signed by Nucleo, bound to the
        store and one order, valid 60 minutes; not renewable — look the order up again when it expires.
  parameters:
    ReturnToken:
      name: token
      in: path
      required: true
      description: Return token from `POST /returns` (`data.token`) or the order view's `returns[].token`.
      schema:
        type: string
        pattern: '^[A-Za-z0-9]{32,64}$'
      example: Xk2Lm9Qa7Rt4Vb1Nc8Wd3Ye6Zf0Gh5Jp2Ks7Lq4M
  responses:
    PortalNotFound:
      description: |
        The store does not exist or its returns portal is switched off. The body is an empty message.
        A slug that does not match `^[a-z0-9][a-z0-9-]{0,63}$` gets the generic route-not-found message.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            portalOff:
              value:
                message: ''
            badSlug:
              value:
                message: The route api/oms/v1/public/returns/Acme_Store could not be found.
    SessionExpired:
      description: '`session_expired`: missing, invalid or expired `X-Return-Session`, or a session of another store.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            expired:
              value:
                message: session_expired
    TooManyRequests:
      description: Rate limit exceeded. No `Retry-After` header; wait `retry_after` seconds.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RateLimited'
          examples:
            tooMany:
              value:
                message: too_many_requests
                retry_after: 42
    ValidationError:
      description: Invalid request body.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'
          examples:
            missingOrder:
              value:
                message: The order field is required.
                errors:
                  order:
                    - The order field is required.
  schemas:
    Language:
      type: string
      enum: [it, en, de, fr, es]
    Message:
      type: object
      required: [message]
      properties:
        message:
          type: string
    RateLimited:
      type: object
      required: [message, retry_after]
      properties:
        message:
          type: string
          enum: [too_many_requests, too_many_attempts]
        retry_after:
          type: integer
          description: Seconds until the limit resets.
    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
    Brand:
      type: object
      required: [name, logo_url, accent_color, support_email, support_url, slug]
      properties:
        name:
          type: string
          example: Acme Apparel
        logo_url:
          type: [string, 'null']
          format: uri
        accent_color:
          type: [string, 'null']
          example: '#0F766E'
        support_email:
          type: [string, 'null']
          format: email
        support_url:
          type: [string, 'null']
          format: uri
        slug:
          type: string
          example: acme
    Resolutions:
      type: object
      description: Which outcomes the shopper may choose.
      required: [refund, exchange, store_credit]
      properties:
        refund:
          type: boolean
        exchange:
          type: boolean
        store_credit:
          type: boolean
    PortalInfo:
      type: object
      required: [brand, languages, default_language, verify_with, window_days, fee, resolutions]
      properties:
        brand:
          $ref: '#/components/schemas/Brand'
        languages:
          type: array
          items:
            $ref: '#/components/schemas/Language'
        default_language:
          $ref: '#/components/schemas/Language'
        verify_with:
          type: array
          description: What the shopper may give besides the order number.
          items:
            type: string
            enum: [email, zip]
        window_days:
          type: integer
          description: Default return window in days from delivery (per-country/channel windows may differ; see `order.returnable_until`).
          example: 30
        fee:
          type: number
          description: Return fee withheld from refunds, in the order currency (0 = free).
          example: 4.9
        resolutions:
          $ref: '#/components/schemas/Resolutions'
    LookupRequest:
      type: object
      required: [order, verifier]
      properties:
        order:
          type: string
          maxLength: 40
          description: Order number, with or without `#`.
          example: '#1042'
        verifier:
          type: string
          maxLength: 120
          description: The email used for the order or the shipping postcode (whichever the policy allows).
          example: giulia.rossi@example.com
        language:
          type: [string, 'null']
          maxLength: 5
          description: Preferred language (`it`, `en`, `de`, `fr`, `es`); ignored if not enabled.
          example: it
    OrderBlock:
      type: [string, 'null']
      description: |
        Why the whole order cannot be returned (`null` = it can): `cancelled`, `not_shipped`,
        `window_closed`, `limit_reached` (the customer reached the policy's maximum returns per period).
      enum: [cancelled, not_shipped, window_closed, limit_reached, null]
    LineBlock:
      type: [string, 'null']
      description: |
        Why this line cannot be returned (`null` = it can). Either the order-level reason, or
        `excluded_sku` (non-returnable product), `excluded_tag` (order tagged as non-returnable),
        `final_sale` (discount above the policy threshold), `not_shipped`, `already_returned`.
      enum: [cancelled, not_shipped, window_closed, limit_reached, excluded_sku, excluded_tag, final_sale, already_returned, null]
    OrderView:
      type: object
      required: [order, lines, reasons, resolutions, store_credit_bonus_pct, methods, fee, exchange, returns]
      properties:
        order:
          type: object
          required: [name, placed_at, delivered_at, currency, country, returnable_until, blocked, language]
          properties:
            name:
              type: string
              example: '#1042'
            placed_at:
              type: [string, 'null']
              format: date-time
            delivered_at:
              type: [string, 'null']
              format: date-time
            currency:
              type: string
              example: EUR
            country:
              type: [string, 'null']
              description: Shipping country.
              example: IT
            returnable_until:
              type: [string, 'null']
              format: date-time
              description: End of the return window (from delivery, or shipment + 3 days if delivery is unknown).
            blocked:
              $ref: '#/components/schemas/OrderBlock'
            language:
              type: [string, 'null']
              description: Language the order was placed in.
        lines:
          type: array
          items:
            $ref: '#/components/schemas/OrderLine'
        reasons:
          type: array
          description: Active return reasons, labelled in the session language.
          items:
            type: object
            required: [code, label, photo]
            properties:
              code:
                type: string
                description: |
                  Stable code (defaults: `too_small`, `too_big`, `style`, `color`, `defective`, `wrong_item`,
                  `not_as_described`, `changed_mind`, `other`; merchants can add their own).
                example: too_small
              label:
                type: string
                example: Too small
              photo:
                type: string
                enum: [none, optional, required]
                description: Whether a photo is asked for when this reason is chosen.
        resolutions:
          $ref: '#/components/schemas/Resolutions'
        store_credit_bonus_pct:
          type: number
          description: Extra percentage granted when choosing store credit instead of a refund.
          example: 10
        methods:
          type: object
          required: [carrier_label, store_dropoff, stores]
          properties:
            carrier_label:
              type: boolean
              description: A prepaid carrier label can be generated.
            store_dropoff:
              type: boolean
              description: The items can be dropped off in one of `stores`.
            stores:
              type: array
              description: Drop-off stores, those in the order's shipping country first.
              items:
                type: object
                required: [id, name, country, address]
                properties:
                  id:
                    type: string
                    format: uuid
                    description: Pass as `store_id` when creating a `store_dropoff` return.
                  name:
                    type: string
                  country:
                    type: [string, 'null']
                  address:
                    type: [string, 'null']
        fee:
          type: object
          required: [amount, free_for_exchange, free_for_store_credit, free_in_store, free_reasons]
          properties:
            amount:
              type: number
              description: Return fee withheld from the refund.
            free_for_exchange:
              type: boolean
            free_for_store_credit:
              type: boolean
            free_in_store:
              type: boolean
            free_reasons:
              type: array
              description: Reason codes for which the return is free.
              items:
                type: string
        exchange:
          type: object
          required: [upcharge, absorb_max, downcharge]
          properties:
            upcharge:
              type: string
              enum: [invoice, absorb]
              description: If the replacement costs more, the shopper pays the difference (`invoice`) or the merchant absorbs it up to `absorb_max`.
            absorb_max:
              type: number
            downcharge:
              type: string
              enum: [refund, store_credit]
              description: If the replacement costs less, how the difference goes back to the shopper.
        returns:
          type: array
          description: Previous returns of this order (cancelled ones excluded), newest first.
          items:
            type: object
            required: [name, status, token, created_at]
            properties:
              name:
                type: string
                example: '#1042-R1'
              status:
                $ref: '#/components/schemas/PublicStatus'
              token:
                type: [string, 'null']
                description: Return token for the status page.
              created_at:
                type: [string, 'null']
                format: date-time
    OrderLine:
      type: object
      required: [id, title, size, color, sku, image_url, price, quantity, returnable, blocked, exchange_options]
      properties:
        id:
          type: string
          format: uuid
          description: Order line ID, to reference in `lines[].id` when creating the return.
        title:
          type: string
          example: Organic Cotton Tee
        size:
          type: [string, 'null']
          description: Parsed from SKUs shaped `MODEL_COLOR_SIZE`; `null` otherwise.
          example: M
        color:
          type: [string, 'null']
          example: BLK
        sku:
          type: [string, 'null']
          example: TEE_BLK_M
        image_url:
          type: [string, 'null']
          format: uri
          description: Product image from the Nucleo Catalog.
        price:
          type: number
          description: Unit price paid, tax included.
          example: 39
        quantity:
          type: integer
          description: Quantity ordered.
        returnable:
          type: integer
          description: Quantity that can still be returned (shipped minus already in non-cancelled returns).
        blocked:
          $ref: '#/components/schemas/LineBlock'
        exchange_options:
          type: array
          description: |
            Other sizes (and colours, if the policy allows) of the same model, same colour first. Empty when the
            line is blocked, exchanges are off, or the SKU is not shaped `MODEL_COLOR_SIZE`.
          items:
            type: object
            required: [barcode, size, color, same_color, price, in_stock, image_url]
            properties:
              barcode:
                type: string
                description: Pass as `exchange_barcode`.
                example: '8001234567892'
              size:
                type: [string, 'null']
                example: L
              color:
                type: [string, 'null']
                example: BLK
              same_color:
                type: boolean
              price:
                type: number
                description: Current price of the replacement.
              in_stock:
                type: boolean
              image_url:
                type: [string, 'null']
                format: uri
    CreateReturn:
      type: object
      required: [method, lines]
      properties:
        method:
          type: string
          enum: [carrier_label, store_dropoff]
          description: Must be offered in the order view's `methods`.
        store_id:
          type: string
          format: uuid
          description: Required for `store_dropoff`; one of `methods.stores[].id`.
        language:
          $ref: '#/components/schemas/Language'
        note:
          type: string
          description: Free note for the merchant (trimmed, truncated at 2,000 characters).
        lines:
          type: array
          minItems: 1
          items:
            type: object
            required: [id, quantity, reason]
            properties:
              id:
                type: string
                format: uuid
                description: Order line ID from `lines[].id`.
              quantity:
                type: integer
                minimum: 1
                description: At most the line's `returnable`.
              reason:
                type: string
                description: An active reason `code`.
                example: too_small
              resolution:
                type: string
                enum: [refund, exchange, store_credit]
                default: refund
              exchange_barcode:
                type: string
                description: Required for `exchange`; one of the line's `exchange_options[].barcode`, in stock.
              comment:
                type: string
                description: Free comment on the line (trimmed, truncated at 1,000 characters).
    PublicStatus:
      type: string
      description: |
        The return in the shopper's words: `requested` (created, not yet on its way), `in_transit`,
        `received`, `checking` (received, refund under manual review), `completed`, `refunded`, `cancelled`.
      enum: [requested, in_transit, received, checking, completed, refunded, cancelled]
    ReturnStatus:
      type: object
      required: [brand, language, return]
      properties:
        brand:
          $ref: '#/components/schemas/Brand'
        language:
          $ref: '#/components/schemas/Language'
        return:
          type: object
          required: [name, order_name, status, created_at, received_at, refunded_at, method, store, dropoff_code, qr_svg, carrier, tracking_number, has_label, currency, fee, refund_amount, store_credit_amount, refund_status, exchange, lines]
          properties:
            name:
              type: string
              example: '#1042-R1'
            order_name:
              type: [string, 'null']
              example: '#1042'
            status:
              $ref: '#/components/schemas/PublicStatus'
            created_at:
              type: [string, 'null']
              format: date-time
            received_at:
              type: [string, 'null']
              format: date-time
            refunded_at:
              type: [string, 'null']
              format: date-time
            method:
              type: [string, 'null']
              enum: [carrier_label, store_dropoff, null]
            store:
              type: [object, 'null']
              description: Drop-off store, for `store_dropoff`.
              properties:
                name:
                  type: string
                address:
                  type: [string, 'null']
            dropoff_code:
              type: [string, 'null']
              description: Code to show at the store desk (also encoded in `qr_svg`).
              pattern: '^R[A-Z0-9]{4}-[A-Z0-9]{5}$'
              example: RK7MP-2XQ9D
            qr_svg:
              type: [string, 'null']
              description: Inline SVG QR code of `dropoff_code`.
            carrier:
              type: [string, 'null']
              description: Return carrier name, when a label was generated.
            tracking_number:
              type: [string, 'null']
            has_label:
              type: boolean
              description: A label can be downloaded from `/r/{token}/label`.
            currency:
              type: [string, 'null']
              example: EUR
            fee:
              type: number
              description: Return fee withheld.
            refund_amount:
              type: number
            store_credit_amount:
              type: number
            refund_status:
              type: [string, 'null']
              description: |
                `waiting` (for the items), `review` (manual check), `queued`, `issued`, `failed`,
                `not_due` (nothing to refund, e.g. a straight exchange), `none`.
              enum: [none, waiting, review, queued, issued, failed, not_due, null]
            exchange:
              type: [object, 'null']
              description: The replacement order, for exchanges.
              properties:
                name:
                  type: string
                  example: '#1042-EX1'
                mode:
                  type: [string, 'null']
                  enum: [standard, advance, null]
                  description: '`standard` ships when the return arrives intact; `advance` ships immediately.'
                shipped:
                  type: boolean
                difference:
                  type: number
                  description: Replacement value minus credit from the return (positive = shopper owes).
                difference_status:
                  type: [string, 'null']
                  enum: [none, due, paid, absorbed, refund, credit, null]
                lines:
                  type: array
                  items:
                    type: object
                    properties:
                      title:
                        type: string
                      quantity:
                        type: integer
            lines:
              type: array
              items:
                type: object
                required: [title, size, image_url, quantity, reason, resolution, exchange_title]
                properties:
                  title:
                    type: [string, 'null']
                  size:
                    type: [string, 'null']
                  image_url:
                    type: [string, 'null']
                    format: uri
                  quantity:
                    type: integer
                  reason:
                    type: [string, 'null']
                    description: Reason label in the return's language.
                  resolution:
                    type: string
                    enum: [refund, exchange, store_credit]
                  exchange_title:
                    type: [string, 'null']
  examples:
    LookupFound:
      summary: Delivered order, one returnable line
      value:
        data:
          session: eyJpdiI6IkxQb3Z6c2J0c0Z6V2Z6dz09IiwidmFsdWUiOiJ3c3l6Q2R0Wm1rRk9TVnlqK2c9PSIsIm1hYyI6IjhhM2YifQ==
          expires_in: 3600
          language: it
          order:
            name: '#1042'
            placed_at: '2026-09-20T10:12:00+00:00'
            delivered_at: '2026-09-23T14:30:00+00:00'
            currency: EUR
            country: IT
            returnable_until: '2026-10-23T23:59:59+00:00'
            blocked: null
            language: it
          lines:
            - id: 9d3f6b1e-2a4c-4e8b-a1f0-6c2d8e4b7a10
              title: Organic Cotton Tee
              size: M
              color: BLK
              sku: TEE_BLK_M
              image_url: https://cdn.acme.example/products/tee-blk.jpg
              price: 39
              quantity: 1
              returnable: 1
              blocked: null
              exchange_options:
                - barcode: '8001234567892'
                  size: L
                  color: BLK
                  same_color: true
                  price: 39
                  in_stock: true
                  image_url: https://cdn.acme.example/products/tee-blk.jpg
                - barcode: '8001234567908'
                  size: M
                  color: WHT
                  same_color: false
                  price: 39
                  in_stock: false
                  image_url: https://cdn.acme.example/products/tee-wht.jpg
          reasons:
            - code: too_small
              label: Troppo piccolo
              photo: none
            - code: defective
              label: Difettoso o danneggiato
              photo: required
          resolutions:
            refund: true
            exchange: true
            store_credit: true
          store_credit_bonus_pct: 10
          methods:
            carrier_label: true
            store_dropoff: true
            stores:
              - id: 4b8e2c71-0f3a-4d59-9e6b-2a7c1d8f5e03
                name: Acme Apparel Milano
                country: IT
                address: Via Roma 1, 20121 Milano
          fee:
            amount: 4.9
            free_for_exchange: true
            free_for_store_credit: true
            free_in_store: true
            free_reasons: [defective, wrong_item]
          exchange:
            upcharge: invoice
            absorb_max: 0
            downcharge: refund
          returns: []
    StatusCarrier:
      summary: Refund with carrier label, just created
      value:
        data:
          brand:
            name: Acme Apparel
            logo_url: https://cdn.acme.example/logo.svg
            accent_color: '#0F766E'
            support_email: help@example.com
            support_url: https://shop.acme.example/pages/contact
            slug: acme
          language: it
          return:
            name: '#1042-R1'
            order_name: '#1042'
            status: requested
            created_at: '2026-10-04T09:15:00+00:00'
            received_at: null
            refunded_at: null
            method: carrier_label
            store: null
            dropoff_code: RK7MP-2XQ9D
            qr_svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 174 174">…</svg>'
            carrier: DHL Express
            tracking_number: '1234567890'
            has_label: true
            currency: EUR
            fee: 4.9
            refund_amount: 0
            store_credit_amount: 0
            refund_status: waiting
            exchange: null
            lines:
              - title: Organic Cotton Tee
                size: M
                image_url: https://cdn.acme.example/products/tee-blk.jpg
                quantity: 1
                reason: Troppo piccolo
                resolution: refund
                exchange_title: null
    StatusStoreExchange:
      summary: Exchange dropped off in store
      value:
        data:
          brand:
            name: Acme Apparel
            logo_url: null
            accent_color: null
            support_email: help@example.com
            support_url: null
            slug: acme
          language: en
          return:
            name: '#1042-R2'
            order_name: '#1042'
            status: requested
            created_at: '2026-10-04T09:20:00+00:00'
            received_at: null
            refunded_at: null
            method: store_dropoff
            store:
              name: Acme Apparel Milano
              address: Via Roma 1, 20121 Milano
            dropoff_code: RW3TB-8HJ4N
            qr_svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 174 174">…</svg>'
            carrier: null
            tracking_number: null
            has_label: false
            currency: EUR
            fee: 0
            refund_amount: 0
            store_credit_amount: 0
            refund_status: waiting
            exchange:
              name: '#1042-EX1'
              mode: standard
              shipped: false
              difference: 0
              difference_status: none
              lines:
                - title: Organic Cotton Tee · L
                  quantity: 1
            lines:
              - title: Organic Cotton Tee
                size: M
                image_url: https://cdn.acme.example/products/tee-blk.jpg
                quantity: 1
                reason: Too small
                resolution: exchange
                exchange_title: Organic Cotton Tee · L
