openapi: 3.1.0

# ---------------------------------------------------------------------------------------------------
# Hand-written on purpose.
#
# Generating this from annotations would mean annotating 24 operations across five controllers and
# adding a dependency, and the result would describe the code's shape rather than the contract an
# integrator can rely on. This file is the contract: what a partner may depend on, what is advisory,
# and what is deliberately absent. ConnectApiContractTest fails the build if a /connect/v1 route
# exists in the code and not here, so it cannot quietly fall behind.
#
# Served unauthenticated at GET /connect/v1/openapi.yaml.
# ---------------------------------------------------------------------------------------------------

info:
  title: Scoperly Connect API
  version: "1.5.0"
  summary: Catalog, stock and order integration for suppliers.
  description: |
    The integration surface for supplier-side systems — ERPs, warehouse software, Scoperly Studio.

    **Identity.** A supplier API key (`sck_live_*`) is exchanged for a short-lived JWT at
    `/auth/token`; every other call carries that JWT. Call `/me` once during setup to confirm which
    account and which side the key belongs to before publishing anything.

    **Catalog grain.** One catalog entry is one sellable SKU, addressed by an `externalId` you choose.
    Scoperly stores that identifier and gives it back on every order line as `sellerExternalId`, so a
    line can always be mapped home even after you re-import your catalog.

    **Prices are not part of this API.** A product has no single price here — pricing is per buyer
    through price lists, managed in the supplier portal. Sending `unitPrice` or `currency` is rejected
    rather than ignored.

    **Consuming orders.** Use the change feed (`?updatedAfter=`, then the returned `nextCursor`), not
    the browse cursor. The browse cursor walks backwards through history and will never show you an
    order created after you stored it. Delivery is at-least-once; deduplicate by order id and status.

    **Rate limits.** 60 requests per minute per API key; 10 per minute per IP when unauthenticated.
    Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`;
    a 429 carries `Retry-After`. Honour the headers rather than retrying blind.

    **Idempotency.** `Idempotency-Key` is bound to the request body. Retrying the identical request
    replays the stored response with `Idempotent-Replayed: true`; sending a different body under a
    key already used is refused with 409 `IDEMPOTENCY_KEY_REUSED` rather than silently discarded.
    Use a fresh key per distinct request; stored keys expire after 48 hours.

    **Errors** are RFC 9457 problem documents. Match on `type`, never on the status code alone.

servers:
  - url: https://api.scoperly.com
    description: Production

tags:
  - name: Authentication
  - name: Catalog
  - name: Orders
  - name: Studio instances
    description: Private surface used by Scoperly Studio installations. Documented, not for third parties.

security:
  - connectJwt: []

paths:

  # ---- Authentication ---------------------------------------------------------------------------

  /connect/v1/auth/token:
    post:
      tags: [Authentication]
      summary: Exchange an API key for an access token
      security:
        - apiKey: []
      responses:
        "200":
          description: Access and refresh tokens issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "401": { $ref: "#/components/responses/Problem" }

  /connect/v1/auth/refresh:
    post:
      tags: [Authentication]
      summary: Rotate an access token
      description: |
        Sessions have a hard upper bound of 7 days from first issue. Past that, refresh stops working
        and a fresh exchange with the API key is required — plan for it rather than discovering it.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: New token pair. The previous refresh token is invalidated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "401": { $ref: "#/components/responses/Problem" }

  /connect/v1/me:
    get:
      tags: [Authentication]
      summary: Which account does this credential belong to
      description: |
        No scope required — a credential may always ask who it is. Check `side` and `tenantSlug` during
        setup: a key for the wrong account authenticates perfectly well, and without this check the
        mistake surfaces only after data has moved.
      responses:
        "200":
          description: Tenant and key identity.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Identity" }
        "401": { $ref: "#/components/responses/Problem" }

  # ---- Catalog ----------------------------------------------------------------------------------

  /connect/v1/supplier/catalog/products:
    get:
      tags: [Catalog]
      summary: List your catalog as Scoperly holds it
      description: |
        Read your own catalog back to detect divergence — an edit made in the portal, a delivery that
        was lost, a queue row that died. Nothing else in this API will tell you about those.
      parameters:
        - { name: cursor, in: query, schema: { type: integer, format: int64 } }
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 100 } }
      responses:
        "200":
          description: One page of products.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductPage" }
    post:
      tags: [Catalog]
      summary: Create or update one product
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpsert" }
      responses:
        "200":
          description: The stored product.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "422": { $ref: "#/components/responses/Problem" }

  /connect/v1/supplier/catalog/products/batch:
    post:
      tags: [Catalog]
      summary: Create or update up to 100 products
      description: |
        Per-item outcomes: `results` lists what was stored, `errors` lists what was rejected and why.
        The request itself still returns 200. An `errors` array is always present — treat its absence
        as a response you could not interpret rather than as success.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 100
              items: { $ref: "#/components/schemas/ProductUpsert" }
      responses:
        "200":
          description: Per-item outcomes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogBatchResult" }
        "400": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "413":
          description: Request body exceeds 2 MiB.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /connect/v1/supplier/catalog/products/search:
    get:
      tags: [Catalog]
      summary: Search your catalog
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [DRAFT, ACTIVE, PAUSED, DISCONTINUED] } }
        - { name: cursor, in: query, schema: { type: integer, format: int64 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        "200":
          description: One page of matches.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductPage" }

  /connect/v1/supplier/catalog/products/{externalId}:
    delete:
      tags: [Catalog]
      summary: Withdraw a product from the marketplace
      description: |
        A repeat DELETE returns 404 — the withdrawn product is no longer visible to this API.
        Replaying the same `Idempotency-Key` is the exception: that returns the cached 204.
        Treat 404-on-delete as already-deleted, not as failure.
      parameters:
        - $ref: "#/components/parameters/ExternalId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "204": { description: Withdrawn. }
        "404": { $ref: "#/components/responses/Problem" }

  /connect/v1/supplier/catalog/products/{externalId}/stock:
    put:
      tags: [Catalog]
      summary: Set available quantity for one product
      parameters:
        - $ref: "#/components/parameters/ExternalId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [availableQty]
              properties:
                availableQty: { type: string, description: Decimal, not a float. }
      responses:
        "200":
          description: The stored product.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
        "400":
          $ref: "#/components/responses/Problem"
          description: |
            `availableQty` missing or out of range. Unlimited stock (null quantity) is a
            portal-managed state, not settable here.
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/catalog/products/batch/stock:
    put:
      tags: [Catalog]
      summary: Set available quantity for up to 50 products
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [externalId, availableQty]
                    properties:
                      externalId: { type: string }
                      availableQty: { type: string }
      responses:
        "200":
          description: Count stored plus per-item errors.
          content:
            application/json:
              schema:
                type: object
                properties:
                  succeeded: { type: integer }
                  errors:
                    type: array
                    items: { $ref: "#/components/schemas/BatchError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/catalog/products/{externalId}/images/presign:
    post:
      tags: [Catalog]
      summary: Reserve a place for a product image
      description: |
        Step one of two. Returns a URL to PUT the bytes to, directly to storage — they never pass
        through this API, which keeps a multi-megabyte photograph off the request budget that catalog
        and order traffic share.

        Send the same `sha256` here and at commit. If your local image changed between the two calls,
        the commit is refused rather than publishing a different picture under the handle you reserved.

        Not idempotency-keyed on purpose: a retried presign mints a fresh handle and URL. The object
        behind an abandoned handle is never published and is reclaimed by storage lifecycle rules.
      parameters:
        - $ref: "#/components/parameters/ExternalId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contentType, sha256, byteSize]
              properties:
                contentType: { type: string, enum: ["image/jpeg", "image/png", "image/webp"] }
                sha256: { type: string, pattern: "^[0-9a-f]{64}$" }
                byteSize: { type: integer, minimum: 1, maximum: 5242880 }
      responses:
        "200":
          description: Upload target. PUT the bytes with the same Content-Type and NO Authorization header.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mediaId: { type: string, description: Opaque handle. Pass to commit; do not parse. }
                  uploadUrl: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }
        "404":
          $ref: "#/components/responses/Problem"
          description: "`PRODUCT_NOT_FOUND` — no product with that `externalId`."
        "422":
          $ref: "#/components/responses/Problem"
          description: "`MEDIA_TYPE_NOT_SUPPORTED` or `MEDIA_TOO_LARGE`."

  /connect/v1/supplier/catalog/products/{externalId}/images/commit:
    post:
      tags: [Catalog]
      summary: Publish an uploaded image
      description: |
        Step two. Until this call the upload is invisible to buyers. Replaces the product's picture
        rather than adding to it — a listing shows one photograph.

        Read-after-write: the original is readable at the returned URL as soon as this call returns.
        Thumbnail variants are generated right after commit, best-effort — a listing may show the
        original for a moment before its resized variants exist.
      parameters:
        - $ref: "#/components/parameters/ExternalId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mediaId, sha256]
              properties:
                mediaId: { type: string }
                sha256: { type: string, pattern: "^[0-9a-f]{64}$" }
      responses:
        "200":
          description: What the product now shows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  externalId: { type: string }
                  images: { type: array, items: { type: string, format: uri } }
        "404":
          $ref: "#/components/responses/Problem"
          description: "`PRODUCT_NOT_FOUND` — no product with that `externalId`."
        "422":
          $ref: "#/components/responses/Problem"
          description: |
            `MEDIA_HANDLE_INVALID` — unknown handle, another product's handle, an expired
            reservation, or a `sha256` that no longer matches what was presigned.

  /connect/v1/supplier/catalog/products/{externalId}/images:
    delete:
      tags: [Catalog]
      summary: Remove the product's image
      parameters:
        - $ref: "#/components/parameters/ExternalId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The product now shows no image.
          content:
            application/json:
              schema:
                type: object
                properties:
                  externalId: { type: string }
                  images: { type: array, items: { type: string } }
        "404":
          $ref: "#/components/responses/Problem"
          description: "`PRODUCT_NOT_FOUND` — no product with that `externalId`."

  /connect/v1/supplier/catalog/sync/start:
    post:
      tags: [Catalog]
      summary: Open a synchronisation session
      description: |
        Bookkeeping, not a prerequisite: it records that a full pass ran, how much it covered and
        whether it finished. Useful when you need to prove a catalog was published completely.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [integrationType, direction]
              properties:
                integrationType: { type: string, enum: [SMART_SALE, ONE_C, HAYKAKAN_CRAGRER, CUSTOM, STUDIO] }
                direction: { type: string, enum: [INBOUND, OUTBOUND, BOTH] }
                totalItems: { type: integer, minimum: 0, maximum: 1000000 }
      responses:
        "201":
          description: Session opened.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string, format: uuid }
                  status: { type: string }

  /connect/v1/supplier/catalog/sync/{sessionId}/end:
    post:
      tags: [Catalog]
      summary: Close a synchronisation session
      parameters:
        - { name: sessionId, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Session closed with its counters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string, format: uuid }
                  status: { type: string }
                  processedItems: { type: integer }
                  failedItems: { type: integer }

  /connect/v1/supplier/catalog/sync/history:
    get:
      tags: [Catalog]
      summary: Recent synchronisation sessions
      responses:
        "200":
          description: The last 20 sessions.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer, format: int64 }
                    uuid: { type: string, format: uuid }
                    integrationType: { type: string }
                    direction: { type: string }
                    status: { type: string }
                    totalItems: { type: [integer, "null"] }
                    processedItems: { type: integer }
                    failedItems: { type: integer }
                    errorSummary: { type: string, description: JSON-encoded array, as a string. }
                    startedAt: { type: string, format: date-time }
                    completedAt: { type: [string, "null"], format: date-time }

  # ---- Orders -----------------------------------------------------------------------------------

  /connect/v1/supplier/orders:
    get:
      tags: [Orders]
      summary: List orders, or walk the change feed
      description: |
        **Change feed (use this).** Pass `updatedAfter` once, then keep passing the returned
        `nextCursor`. Orders come back in the order they last changed, so a status change on an order
        you already read is delivered again. `nextCursor` is always present, including on an empty
        page, so you never lose your place. `hasMore: false` means "caught up", not "finished".

        **Browse.** A bare numeric `cursor` walks backwards, newest first. Fine for showing a recent
        list; wrong for synchronisation — an order created after you stored the cursor has a higher id
        and will never appear.

        **The feed trails real time by about 5 seconds.** An order becomes visible on the feed a few
        seconds after it is committed, so that a transaction which took a while to commit cannot land
        behind a cursor another poll already advanced past. Poll at whatever interval suits you; the
        lag affects freshness only. At-least-once delivery still holds — keep deduplicating by
        order id and status.
      parameters:
        - name: updatedAfter
          in: query
          description: ISO-8601 instant. Selects the change feed. Use `1970-01-01T00:00:00Z` for a full backfill.
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: Feed mode — the opaque `nextCursor` from the previous page. Browse mode — the last order id.
          schema: { type: string }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
        - name: status
          in: query
          schema: { type: string, enum: [PLACED, ACCEPTED, PREPARING, DISPATCHED, DELIVERED, REJECTED, CANCELLED] }
      responses:
        "200":
          description: One page of orders.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderPage" }
        "400": { $ref: "#/components/responses/Problem" }

  /connect/v1/supplier/orders/{orderId}:
    get:
      tags: [Orders]
      summary: Full order detail
      description: |
        Includes the line items, a reduced delivery location (city and country only) and, when the
        buyer matches an entry in your own partner list, your classification of them.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: The order and, when matched, your partner record for the buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
                  internalPartner:
                    oneOf:
                      - $ref: "#/components/schemas/InternalPartner"
                      - type: "null"
        "404": { $ref: "#/components/responses/Problem" }

  /connect/v1/supplier/orders/{orderId}/accept:
    post:
      tags: [Orders]
      summary: Accept a placed order
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                confirmedDeliveryDate: { type: string, format: date }
      responses:
        "200":
          description: The order in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "409":
          $ref: "#/components/responses/Problem"
          description: "`ORDER_VERSION_CONFLICT` — the order changed since the version in `If-Match`."
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/orders/{orderId}/reject:
    post:
      tags: [Orders]
      summary: Reject a placed order
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500, description: Shown to the buyer. }
      responses:
        "200":
          description: The order in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/orders/{orderId}/dispatch:
    post:
      tags: [Orders]
      summary: Mark an order dispatched
      description: |
        Refused with 422 `AMENDMENT_IN_FLIGHT` while an amendment is awaiting the buyer's
        confirmation — dispatching then would ship goods against terms nobody has agreed.
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                trackingCode: { type: string, maxLength: 200 }
                notes: { type: string, maxLength: 1000 }
      responses:
        "200":
          description: Dispatched.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
        "422":
          $ref: "#/components/responses/Problem"
          description: "`AMENDMENT_IN_FLIGHT`, or the order is not in a dispatchable state."
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/orders/{orderId}/cancel:
    post:
      tags: [Orders]
      summary: Cancel an order you have taken
      description: |
        Allowed while the order is PLACED, ACCEPTED or PREPARING. Once dispatched the goods are with
        the buyer and cancelling is a dispute, not a state change. Stock and credit are compensated
        exactly as for a cancellation made in the portal.

        Call this whenever your own system cancels a marketplace order. Skipping it leaves the buyer
        watching an order being fulfilled that you have already written off.
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        "200":
          description: The order in its new state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "422":
          $ref: "#/components/responses/Problem"
          description: "`ORDER_CANNOT_CANCEL` (already dispatched or terminal) or `AMENDMENT_IN_FLIGHT`."
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/orders/batch/accept:
    post:
      tags: [Orders]
      summary: Accept up to 50 orders
      description: |
        Two body shapes are accepted — send exactly one. `orderIds` is the minimal form; `orders`
        mirrors batch dispatch so an integrator can carry per-item metadata (unknown item fields are
        ignored). Both are processed identically.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                orderIds:
                  type: array
                  maxItems: 50
                  items: { type: integer, format: int64 }
                orders:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [orderId]
                    properties:
                      orderId: { type: integer, format: int64 }
              oneOf:
                - required: [orderIds]
                - required: [orders]
      responses:
        "200":
          description: Per-order outcomes; the request itself succeeds.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderBatchResult" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /connect/v1/supplier/orders/batch/dispatch:
    post:
      tags: [Orders]
      summary: Dispatch up to 50 orders
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orders]
              properties:
                orders:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [orderId]
                    properties:
                      orderId: { type: integer, format: int64 }
                      trackingCode: { type: string }
                      notes: { type: string }
      responses:
        "200":
          description: Per-order outcomes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderBatchResult" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---- Studio instances -------------------------------------------------------------------------

  /connect/v1/studio/instances/register:
    post:
      tags: [Studio instances]
      summary: Register a Studio installation
      description: |
        Idempotent on `instanceUuid`; no `Idempotency-Key` here on purpose — the one-time key
        exchange must never be replayed from a cache. Send the current `X-Studio-Instance-Key`
        when re-registering as proof of ownership (key rotation). Only a 200 is success.
      security: []
      parameters:
        - name: X-Studio-Instance-Key
          in: header
          required: false
          description: Rotation proof — the key issued at a previous registration.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instanceUuid, orgTradeName, countryCode]
              properties:
                instanceUuid: { type: string, format: uuid }
                orgTradeName: { type: string, maxLength: 255 }
                countryCode: { type: string, minLength: 2, maxLength: 2 }
                studioVersion: { type: string, maxLength: 32 }
                contactEmail: { type: string, format: email, maxLength: 255 }
      responses:
        "200":
          description: |
            The raw `sik_live_*` instance key — issued once, never retrievable again — plus current
            plan limits.
          content:
            application/json:
              schema:
                type: object
                properties:
                  instanceKey: { type: string }
                  limits: { $ref: "#/components/schemas/StudioLimits" }
        "409":
          $ref: "#/components/responses/Problem"
          description: "`INSTANCE_RECONCILE_REQUIRED` — the uuid is known but ownership was not proven."
        "410":
          $ref: "#/components/responses/Problem"
          description: "`INSTANCE_GONE` — the installation was removed on the server."

  /connect/v1/studio/instances/{uuid}/heartbeats:
    post:
      tags: [Studio instances]
      summary: Report usage counters
      description: |
        Authenticated by the instance key header. Counters only — no partner names, prices, sums,
        margins, personal data or document contents. Each snapshot carries its own `snapshot_id` and
        is deduplicated on it, so re-sending a batch cannot double-count an installation.
      security: []
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: "#/components/parameters/InstanceKey"
      requestBody:
        required: true
        description: A bare JSON array of snapshot objects, at most 100 per batch.
        content:
          application/json:
            schema:
              type: array
              maxItems: 100
              items: { type: object }
      responses:
        "200":
          description: |
            How many snapshots were accepted after server-side filtering, plus current plan limits —
            the heartbeat doubles as the limits-sync channel.
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: integer }
                  limits: { $ref: "#/components/schemas/StudioLimits" }
        "401":
          $ref: "#/components/responses/Problem"
          description: Invalid instance credentials.

  /connect/v1/studio/instances/{uuid}/link:
    post:
      tags: [Studio instances]
      summary: Link an installation to the authenticated account
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: "#/components/parameters/InstanceKey"
      responses:
        "200":
          description: Linked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StudioLink" }
        "401":
          $ref: "#/components/responses/Problem"
          description: No tenant on the JWT, or an invalid instance key.
        "409":
          $ref: "#/components/responses/Problem"
          description: "`INSTANCE_ALREADY_LINKED` — already bound to a different account."

  /connect/v1/studio/instances/{uuid}/link-by-code:
    post:
      tags: [Studio instances]
      summary: Link an installation with a one-time code
      description: |
        First-run path: no API key exists yet, so the target account comes from the code rather than
        from a session. Codes work once and expire after 24 hours.
      security: []
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: "#/components/parameters/InstanceKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        "200":
          description: Linked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StudioLink" }
        "401":
          $ref: "#/components/responses/Problem"
          description: Invalid instance key.
        "409":
          $ref: "#/components/responses/Problem"
          description: "`INSTANCE_ALREADY_LINKED` — already bound to a different account."
        "422":
          $ref: "#/components/responses/Problem"
          description: |
            `INVALID_LINK_CODE`. Unknown, expired and already-used codes all collapse to this one
            answer on purpose — the response does not reveal which.

  # ---- Contract ---------------------------------------------------------------------------------

  /connect/v1/openapi.yaml:
    get:
      tags: [Authentication]
      summary: This document
      security: []
      responses:
        "200":
          description: The contract, unauthenticated.
          content:
            application/yaml: {}

# ---- Webhooks -----------------------------------------------------------------------------------
#
# Outbound: Scoperly POSTs to HTTPS endpoints registered in the supplier portal. Deliveries go to
# the supplier tenant's endpoints — buyers are people using the portal, not integrators.
#
# Every delivery is the same envelope (see WebhookEnvelope) with two headers:
#   X-Scoperly-Event      the event name below
#   X-Scoperly-Signature  t=<epoch-seconds>,v1=<hex HMAC-SHA256 over "<t>.<rawBody>">
# keyed with the endpoint's whsec_* secret. Verify over the raw bytes, and reject stale timestamps
# (older than ~5 minutes). Any 2xx acknowledges; anything else is retried with exponential backoff
# (5 attempts), and an endpoint that keeps failing is disabled.

webhooks:
  order.placed:
    post:
      summary: A buyer placed an order with you.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. Anything else is retried. }
  order.accepted:
    post:
      summary: The order was accepted.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.rejected:
    post:
      summary: The order was rejected.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.dispatched:
    post:
      summary: The order was dispatched.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.delivered:
    post:
      summary: The buyer confirmed delivery.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.cancelled:
    post:
      summary: The order was cancelled.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.amended:
    post:
      summary: An amendment was confirmed by the buyer.
      description: |
        Only the confirmed step fires. A requested amendment is still a proposal and a rejected one
        changed nothing — neither would give you a reason to re-read the order.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  payment.confirmed:
    post:
      summary: A payment against the order's invoice was confirmed.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }
  order.payment_status_changed:
    post:
      summary: The order's payment status changed.
      description: |
        Fires independently of the lifecycle status — a payment can change while the order sits in
        the same state for weeks. `data` additionally carries `paymentStatus`, the value at the
        moment of the change: two rapid changes produce two distinguishable deliveries.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "2XX": { description: Acknowledged. }

components:

  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        The supplier API key (`sck_live_*`). Accepted on every route; exchanging it for a JWT at
        `/auth/token` is recommended — the per-request check is lighter — but not required.
    connectJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: The short-lived token from `/auth/token`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Replays the stored response instead of acting twice. The key is bound to the request body:
        the same key with a different body is rejected with 409 `IDEMPOTENCY_KEY_REUSED`. Retrying
        the identical request replays the first response with `Idempotent-Replayed: true`. Use a
        fresh key per distinct request. Stored keys expire after 48 hours.
      schema: { type: string }
    IfMatch:
      name: If-Match
      in: header
      required: false
      description: |
        The order's `version` as you last read it. Refused with 409 `ORDER_VERSION_CONFLICT` if the
        order has changed since. Optional; omitting it keeps the previous behaviour.
      schema: { type: string }
    InstanceKey:
      name: X-Studio-Instance-Key
      in: header
      required: true
      schema: { type: string }
    OrderId:
      name: orderId
      in: path
      required: true
      schema: { type: integer, format: int64 }
    ExternalId:
      name: externalId
      in: path
      required: true
      description: Your identifier for the SKU. Percent-encode it.
      schema: { type: string, maxLength: 255 }

  responses:
    Problem:
      description: RFC 9457 problem document. Match on `type`.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: |
        `RATE_LIMIT_EXCEEDED` — the per-key (60/min) or per-IP (10/min unauthenticated) window is
        exhausted. Wait `Retry-After` seconds; the `X-RateLimit-*` headers on every response tell
        you how close you are before it happens.
      headers:
        Retry-After:
          description: Seconds until the window resets.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:

    Problem:
      type: object
      properties:
        type: { type: string, description: Stable machine-readable code. This is what to branch on. }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
      examples:
        - type: PRICE_NOT_SUPPORTED_VIA_CONNECT
          title: Unprocessable Entity
          status: 422
          detail: Prices cannot be set through the catalog API.

    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        token_type: { type: string, description: Always "Bearer". }
        expires_in: { type: integer, description: Seconds. }

    Identity:
      type: object
      properties:
        tenantId: { type: integer, format: int64 }
        tenantUuid:
          type: string
          format: uuid
          description: |
            The stable identity of the account. Never changes for the lifetime of the tenant — pin
            THIS when you bind an installation to an account. `tenantSlug` and `legalName` are
            display values and may be renamed.
        tenantSlug: { type: string, description: Renameable display handle — do not use as a security binding. }
        tenantName: { type: string }
        legalName: { type: string }
        countryCode: { type: string }
        side: { type: string, enum: [SUPPLIER, BUYER] }
        scopes: { type: array, items: { type: string } }
        keyName: { type: string }
        keyPrefix: { type: string }
        planTier: { type: string }
        keyExpiresAt: { type: string, format: date-time }

    ProductUpsert:
      type: object
      required: [externalId, name]
      description: |
        `unitPrice` and `currency` are deliberately absent. Sending them is rejected with
        `PRICE_NOT_SUPPORTED_VIA_CONNECT` rather than silently ignored.
      properties:
        externalId: { type: string, maxLength: 255 }
        name: { type: string, maxLength: 500 }
        categorySlug:
          type: string
          maxLength: 200
          description: >-
            Accepted but IGNORED in contract v1: the product's category is managed in the supplier
            portal and is never changed by a Connect upsert. The field is reserved for a future
            version that resolves it with carry-over semantics; sending it today is harmless.
        unitOfMeasure: { type: string, maxLength: 50 }
        minOrderQty: { type: string }
        stepQty: { type: string }
        availableQty: { type: string }
        leadTimeDays: { type: integer, minimum: 0, maximum: 3650 }
        brand: { type: string, maxLength: 255 }
        barcode: { type: string, maxLength: 100 }
        description: { type: string, maxLength: 8000 }
        attributes: { type: object, maxProperties: 100 }
        isActive: { type: boolean }

    Product:
      type: object
      description: |
        Fields Scoperly holds for the SKU. Internal row identifiers and prices are not exposed;
        beyond the properties below the response may carry further descriptive fields
        (origin country, weight, volume, promotion) — treat it as a superset.
      properties:
        externalId: { type: string }
        name: { type: string }
        status: { type: string }
        availableQty: { type: string }
        unitOfMeasure: { type: string }
        barcode: { type: string }
        minOrderQty: { type: string }
        stepQty: { type: string }
        categoryName: { type: string }
        brandName: { type: string }
        description: { type: string }
        vatRate: { type: string }
        leadTimeDays: { type: integer }
        reorderPoint: { type: string }
        attributes: { type: [string, "null"], description: JSON-encoded object, as a string. }
        images:
          type: [string, "null"]
          description: |
            JSON-encoded array of public image URLs, as a string. This is the read-back for
            `images/commit` — the URL you committed appears here.
        documents: { type: [string, "null"], description: JSON-encoded array, as a string. }
        tags: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ProductPage:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/Product" } }
        nextCursor: { type: [string, "null"] }
        hasMore: { type: boolean }
        totalCount: { type: "null", description: May appear; always null on Connect paths. }

    CatalogBatchResult:
      type: object
      properties:
        succeeded: { type: integer }
        failed: { type: integer }
        results: { type: array, items: { type: object } }
        errors: { type: array, items: { $ref: "#/components/schemas/BatchError" } }

    BatchError:
      type: object
      properties:
        externalId: { type: string }
        status: { type: string, description: Always "failed" on an error entry. }
        errorCode: { type: string }
        error: { type: string }

    OrderBatchResult:
      type: object
      description: |
        A failed item's entry is `{orderId, status: "failed", errorCode, error}`; a successful one
        carries the new status and no error fields.
      properties:
        succeeded: { type: integer }
        failed: { type: integer }
        results:
          type: array
          items:
            type: object
            properties:
              orderId: { type: integer, format: int64 }
              status: { type: string, description: "\"accepted\" / \"dispatched\", or \"failed\"." }
              errorCode: { type: string }
              error: { type: string }

    OrderSummary:
      type: object
      properties:
        id: { type: integer, format: int64 }
        uuid: { type: string, format: uuid }
        orderNumber: { type: string }
        status: { type: string }
        buyerName: { type: string }
        supplierName: { type: string }
        totalAmount: { type: string }
        currency: { type: string }
        paymentStatus: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        deliveredAt: { type: [string, "null"], format: date-time }
        cancelledAt: { type: [string, "null"], format: date-time }
        version: { type: string, description: Opaque. Pass back as If-Match. }

    OrderPage:
      type: object
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/OrderSummary" } }
        nextCursor:
          type: [string, "null"]
          description: In feed mode always present, including on an empty page. Persist it only after the page is stored.
        hasMore: { type: boolean, description: "false means caught up, not finished." }
        totalCount: { type: "null", description: May appear; always null on Connect paths. }

    Order:
      type: object
      properties:
        id: { type: integer, format: int64 }
        uuid: { type: string, format: uuid }
        orderNumber: { type: string }
        status: { type: string }
        buyerId: { type: integer, format: int64 }
        buyerName: { type: string }
        supplierId: { type: integer, format: int64 }
        supplierName: { type: string }
        amendmentStatus: { type: [string, "null"] }
        lines: { type: array, items: { $ref: "#/components/schemas/OrderLine" } }
        subtotal: { type: string }
        deliveryFee: { type: [string, "null"] }
        deliveryFeeSource: { type: [string, "null"] }
        deliveryFeeNotes: { type: [string, "null"] }
        discountAmount: { type: [string, "null"] }
        vatTotal: { type: string }
        totalAmount:
          type: string
          description: Net total (subtotal + delivery − discount). VAT is separate in `vatTotal`.
        currency: { type: string }
        paymentMethod: { type: [string, "null"] }
        paymentStatus: { type: string }
        paymentTerms: { type: [string, "null"] }
        deliveryCity: { type: string }
        deliveryCountry: { type: string }
        requestedDeliveryDate: { type: string, format: date }
        confirmedDeliveryDate: { type: [string, "null"], format: date }
        buyerNotes: { type: [string, "null"] }
        supplierNotes: { type: [string, "null"] }
        rejectionReason: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        acceptedAt: { type: [string, "null"], format: date-time }
        dispatchedAt: { type: [string, "null"], format: date-time }
        deliveredAt: { type: [string, "null"], format: date-time }
        cancelledAt: { type: [string, "null"], format: date-time }
        trackingNumber: { type: [string, "null"] }
        trackingUrl: { type: [string, "null"] }
        carrierName: { type: [string, "null"] }
        version: { type: string }

    OrderLine:
      type: object
      properties:
        id: { type: integer, format: int64 }
        productId: { type: integer, format: int64, description: Scoperly's identifier. Not a join key for you. }
        sellerExternalId:
          type: [string, "null"]
          description: |
            Your own SKU identifier, snapshotted when the order was placed. Join on this: it survives a
            catalog re-import that reassigns external ids. Null for products created in the portal.
        productName: { type: string }
        unitOfMeasure: { type: string }
        quantity: { type: string }
        unitPrice: { type: string }
        vatRate: { type: string }
        vatAmount: { type: string }
        lineTotal: { type: string }
        lineTotalInclVat: { type: string }
        currency: { type: string }

    InternalPartner:
      type: object
      description: Your own classification of the buyer, echoed back. Never another supplier's data.
      properties:
        externalId: { type: string }
        name: { type: string }
        category: { type: string }
        route: { type: string }

    WebhookEnvelope:
      type: object
      description: |
        Every webhook delivery. `data` is a pointer, not a snapshot: enough to react and know where
        to look. Re-read the order through the API for detail — the delivery deliberately carries
        no lines, amounts or address data.
      properties:
        event: { type: string, description: The event name, same as X-Scoperly-Event. }
        timestamp: { type: string, format: date-time }
        data:
          type: object
          properties:
            orderId: { type: integer, format: int64 }
            orderNumber: { type: string }
            status: { type: string }
            updatedAt: { type: string, format: date-time }
            paymentStatus: { type: string, description: Only on order.payment_status_changed. }

    StudioLimits:
      type: object
      description: |
        Plan limits, carried in register and heartbeat responses. Null `memberLimit` or `skuLimit`
        means unlimited; `branchLimit` is never null.
      properties:
        planCode: { type: string }
        memberLimit: { type: [integer, "null"] }
        branchLimit: { type: integer }
        skuLimit: { type: [integer, "null"] }

    StudioLink:
      type: object
      description: The installation is now bound to the tenant.
      properties:
        instanceUuid: { type: string, format: uuid }
        tenantId: { type: integer, format: int64 }
        linked: { type: boolean }
        earlyAccessGranted: { type: boolean }
        earlyAccessUntil: { type: [string, "null"], format: date }
