openapi: 3.1.0
info:
  title: Setu Custom Commerce API
  version: "1"
  summary: The API a store implements so Setu can sell its catalogue in customer conversations.
  description: |
    Contract v1 for the **Custom Website/API** connector in Setu.

    The store implements the operations in this document. Setu is the client.

    **Authentication** — two modes, same resource endpoints:
    - **API key** (`apiKeyBearer`): the merchant generates a key in the store's
      admin and pastes the Store API URL (`https://{storeDomain}/api/setu/v1`)
      and the key into Setu. Discovery, OAuth and webhook operations are not
      used. `GET /store` must return `api_version` and `capabilities`.
    - **OAuth 2.0 + PKCE** (`setuBearer`): discovery document, authorize page,
      tokens with scopes, optional webhooks.

    **Capabilities.** `GET /store` lists what the store supports in
    `capabilities`. Required: `catalog.read`, `orders.write`. Optional
    (additive within v1, Setu uses each one only when it is listed):
    - `shipping.quotes`: `POST /shipping/quotes`; orders must then reference
      the quote with `shipping_quote_id`.
    - `payments.sessions`: online payment options in `payment_options`,
      `POST /orders/{orderId}/payment-sessions`,
      `GET /payment-sessions/{paymentSessionId}` and the optional
      `payment.updated` webhook.
    - `orders.lookup_by_number`: `GET /orders/{orderId}` also accepts the
      customer-facing order number.
    - `orders.cancel`, `orders.fulfil`, `orders.cash_payment`: the merchant
      can cancel, mark fulfilled or record a cash-on-delivery payment from
      Setu's panel through `POST /orders/{orderId}/cancel`, `/fulfil` and
      `/cash-payment`. The store applies its own rules and lists what each
      order allows in `allowed_actions`.
    - `webhooks.manage`: Setu may register a webhook with `PUT /webhooks`.

    A store that lists no optional capability keeps the original behaviour:
    cash-on-delivery orders, with `shipping_minor` taken from
    `POST /checkout/validate`. Setu ignores capability values it does not know.

    Guide: https://setu.vantelyx.dev/developers/custom-commerce-api
    (also `docs/custom-commerce-api/` in the Setu repository):

    - `api-keys.md`: API-key mode, key lifecycle, connection checks, conformance tool
    - `README.md`: overview and connect flow
    - `authorization.md`: discovery, OAuth 2.0 with PKCE, rotation and revocation
    - `endpoints.md`: behaviour of each endpoint
    - `webhooks.md`: events and signatures
    - `errors-and-idempotency.md`: error codes, retries and idempotency
    - `testing-checklist.md`: what to test before connecting a real workspace

    Conventions:
    - JSON bodies use snake_case.
    - Timestamps are RFC 3339 in UTC.
    - Money is an integer count of the currency's minor unit.
    - Ids are opaque store-chosen strings of at most 128 characters.

    The TypeScript types in `packages/custom-commerce-sdk/src/contract.ts`
    mirror this document. When they disagree, this document is authoritative.
  license:
    name: Proprietary (Vantelyx)
servers:
  - url: https://{storeDomain}
    description: |
      The store's own host. The discovery document, token, revoke and authorize
      endpoints are served at the paths the discovery document names. The
      paths below assume the reference layout:
      - `api_base` = `https://{storeDomain}/api/setu/v1`
      - `token_url` = `/oauth/token`
      - `revoke_url` = `/oauth/revoke`
      - `authorize_url` = `/admin/setu/authorize`

      A store may choose other paths, provided the discovery document lists them.
    variables:
      storeDomain:
        default: shop.example.com
tags:
  - name: Discovery
  - name: OAuth
  - name: Store
  - name: Catalogue
  - name: Checkout
  - name: Shipping
  - name: Orders
  - name: Payments
  - name: Webhooks

security:
  - apiKeyBearer: []
  - setuBearer: []

paths:
  /.well-known/setu-commerce.json:
    get:
      tags: [Discovery]
      operationId: getDiscoveryDocument
      summary: Discovery document
      description: |
        Public, unauthenticated, served on the exact domain the owner enters,
        with no redirect. Setu requires `setu_commerce_version` = `"1"`, all
        five scopes, and every URL https on that domain or one of its
        subdomains (port 443, no userinfo).
      security: []
      responses:
        "200":
          description: Discovery document
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DiscoveryDocument" }
              example:
                setu_commerce_version: "1"
                store_name: Example Store
                api_base: https://shop.example.com/api/setu/v1
                authorize_url: https://shop.example.com/admin/setu/authorize
                token_url: https://shop.example.com/oauth/token
                revoke_url: https://shop.example.com/oauth/revoke
                scopes_supported: [catalog:read, inventory:read, orders:read, orders:write, webhooks:manage]

  /admin/setu/authorize:
    get:
      tags: [OAuth]
      operationId: authorize
      summary: Authorize page (browser, `authorize_url`)
      description: |
        This is an HTML page in the store's admin, not a JSON endpoint.

        **Validate the request before rendering anything.** If `redirect_uri`
        is not on the allow-list (exact string match), show an error and do
        **not** redirect.

        Then:
        1. Require the store's admin sign-in.
        2. Show the consent screen with **Authorize Setu** and **Cancel**.
           Protect it with CSRF and anti-framing.

        **Authorize** redirects with `302` to `{redirect_uri}?code&state`.
        **Cancel** redirects with `302` to `{redirect_uri}?error=access_denied&state`.
      security: []
      parameters:
        - { name: response_type, in: query, required: true, schema: { const: code } }
        - { name: client_id, in: query, required: true, schema: { const: setu } }
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string, format: uri, examples: ["https://setu.vantelyx.dev/api/integrations/store/callback"] }
        - name: scope
          in: query
          required: true
          description: Space-separated. Setu requests all five scopes.
          schema: { type: string, examples: ["catalog:read inventory:read orders:read orders:write webhooks:manage"] }
        - name: state
          in: query
          required: true
          description: Opaque. Return it unchanged.
          schema: { type: string, minLength: 16, maxLength: 512 }
        - name: code_challenge
          in: query
          required: true
          description: BASE64URL(SHA-256(code_verifier)), without padding.
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" }
        - { name: code_challenge_method, in: query, required: true, schema: { const: S256 } }
      responses:
        "200":
          description: Sign-in page or consent page (HTML)
          content:
            text/html:
              schema: { type: string }
        "302":
          description: |
            Redirect back to Setu.
            - After **Authorize**: `{redirect_uri}?code=<code>&state=<state>`.
            - After **Cancel**: `{redirect_uri}?error=access_denied&state=<state>`.
          headers:
            Location:
              schema: { type: string, format: uri }
        "400":
          description: Invalid request. Show an error page and do not redirect.
          content:
            text/html:
              schema: { type: string }

  /oauth/token:
    post:
      tags: [OAuth]
      operationId: token
      summary: Token endpoint (`token_url`)
      description: |
        RFC 6749 token endpoint for a public client: no client authentication,
        with PKCE.
        - **Codes** are single-use and live for at most 10 minutes.
        - **Refresh tokens** rotate on every use. Reusing a rotated refresh
          token revokes the whole token family and returns `invalid_grant`.
        - **Caching:** always respond with `Cache-Control: no-store`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              oneOf:
                - $ref: "#/components/schemas/AuthorizationCodeGrant"
                - $ref: "#/components/schemas/RefreshTokenGrant"
              discriminator: { propertyName: grant_type }
      responses:
        "200":
          description: Tokens issued
          headers:
            Cache-Control: { schema: { const: no-store } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: RFC 6749 §5.2 error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenErrorResponse" }
              example: { error: invalid_grant, error_description: "The authorization code is invalid, expired or already used." }
        "401":
          description: "`client_id` is not `setu` (`invalid_client`)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimitedOAuth" }

  /oauth/revoke:
    post:
      tags: [OAuth]
      operationId: revoke
      summary: Revocation endpoint (`revoke_url`, RFC 7009)
      description: |
        Revoking either token of a family revokes the whole family. Always
        returns `200 {}` for a well-formed request, even for an unknown token.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, client_id]
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
                client_id: { const: setu }
      responses:
        "200":
          description: Revoked, or the token was unknown
          content:
            application/json:
              schema: { type: object, maxProperties: 0 }
        "400":
          description: "`token` is missing"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenErrorResponse" }
        "401":
          description: "`client_id` is not `setu`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenErrorResponse" }

  /api/setu/v1/store:
    get:
      tags: [Store]
      operationId: getStore
      summary: Store details
      description: |
        Needs a valid API key or any valid token. In OAuth mode `id` must
        equal `store.id` from the token response. In API-key mode
        `api_version` and `capabilities` are required, and a revoked or
        rotated key must get `401 invalid_token`.

        `capabilities` must include `catalog.read` and `orders.write`. List an
        optional capability (`shipping.quotes`, `payments.sessions`,
        `orders.lookup_by_number`, `orders.cancel`, `orders.fulfil`,
        `orders.cash_payment`, `webhooks.manage`) only when the matching
        endpoints are implemented. With `payments.sessions`,
        `payment_options` is required and `payment_methods` lists the same ids.
      responses:
        "200":
          description: Store
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StoreInfo" }
              examples:
                base:
                  summary: Base v1 store (cash on delivery only)
                  value:
                    id: store_123
                    name: Example Store
                    currency: NPR
                    website_url: https://shop.example.com
                    timezone: Asia/Kathmandu
                    payment_methods: [cash_on_delivery]
                    shipping_quotes: true
                    api_version: "1"
                    capabilities: [catalog.read, orders.write]
                    product_count: 42
                extended:
                  summary: Store with shipping quotes and online payments
                  value:
                    id: store_123
                    name: Example Store
                    currency: NPR
                    website_url: https://shop.example.com
                    timezone: Asia/Kathmandu
                    payment_methods: [cash_on_delivery, wallet_qr]
                    payment_options:
                      - { id: cash_on_delivery, kind: cash_on_delivery, label: Cash on delivery }
                      - id: wallet_qr
                        kind: online
                        label: Pay by QR
                        description: "Scan the QR code on the payment page."
                        max_total_minor: 50000000
                    shipping_quotes: true
                    api_version: "1"
                    capabilities: [catalog.read, orders.write, shipping.quotes, payments.sessions, orders.lookup_by_number]
                    product_count: 42
        "401": { $ref: "#/components/responses/InvalidToken" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/categories:
    get:
      tags: [Catalogue]
      operationId: listCategories
      summary: List categories
      security: [{ apiKeyBearer: [] }, { setuBearer: [catalog:read] }]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/UpdatedSince"
      responses:
        "200":
          description: One page of categories
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CategoryPage" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/products:
    get:
      tags: [Catalogue]
      operationId: listProducts
      summary: List products (all statuses), keyset-paginated by (updated_at, id)
      description: |
        Returns **every** product, including `draft` and `archived`, so Setu
        can hide those. Rows are ordered by `(updated_at, id)` ascending.
        - **`updated_since`:** returns products with `updated_at ≥` the given
          value.
        - **Edits during a sync:** a product edited while Setu pages through
          the list must move to a later page, not be skipped.
      security: [{ apiKeyBearer: [] }, { setuBearer: [catalog:read] }]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/UpdatedSince"
      responses:
        "200":
          description: One page of products
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductPage" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/products/{productId}:
    get:
      tags: [Catalogue]
      operationId: getProduct
      summary: Get one product
      description: "`404 not_found` makes Setu archive its copy of the product."
      security: [{ apiKeyBearer: [] }, { setuBearer: [catalog:read] }]
      parameters:
        - $ref: "#/components/parameters/ProductId"
      responses:
        "200":
          description: Product
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProductResponse" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/checkout/validate:
    post:
      tags: [Checkout]
      operationId: validateCheckout
      summary: Live price and stock check (read-only)
      description: |
        Re-reads live prices and stock and never changes stock. Lines that
        can't be ordered as requested go to `problems` instead of `lines`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [inventory:read] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ValidateRequest" }
            example:
              lines:
                - { product_id: p_42, variant_id: v_42_m_black, quantity: 2 }
              shipping_address: { line1: Near the school, city: Kathmandu, area: Baneshwor, country: NP }
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidateResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/shipping/quotes:
    post:
      tags: [Shipping]
      operationId: quoteShipping
      summary: Quote delivery for a cart and destination (capability `shipping.quotes`)
      description: |
        Only for stores that list `shipping.quotes`. Re-reads live prices and
        stock like `POST /checkout/validate` and prices delivery from the
        store's own rules. It does not reserve stock.

        - **Binding:** the quote is bound to the exact lines (product,
          variant, quantity) and destination it was made for, and to its
          `shipping_minor`.
        - **Expiry:** `expires_at` is typically 15 minutes ahead and at most
          24 hours.
        - **Line problems:** when any line is unknown, inactive or short on
          stock, answer `200` with `quote: null` and the `problems`.
        - **Not served:** a destination the store does not deliver to gets
          `422 delivery_unavailable`.

        `POST /orders` must then send `shipping_quote_id` and the quote's
        `shipping_minor`. Setu never chooses the delivery fee itself for
        such a store.
      security: [{ apiKeyBearer: [] }, { setuBearer: [inventory:read] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShippingQuoteRequest" }
            example:
              lines:
                - { product_id: p_42, variant_id: v_42_m_black, quantity: 2 }
              destination:
                line1: 12 Example Road
                line2: Flat 3
                city: Kathmandu
                area: Baneshwor
                region: Bagmati
                postal_code: "44600"
                country: NP
                latitude: 27.6915
                longitude: 85.342
      responses:
        "200":
          description: A quote, or `quote` null with line problems
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShippingQuoteResponse" }
              example:
                quote:
                  id: q_7Hk2
                  expires_at: "2026-10-12T08:30:00.000Z"
                  currency: NPR
                  lines:
                    - { product_id: p_42, variant_id: v_42_m_black, quantity: 2, title: Linen Wrap Dress, variant_title: M / Black, unit_price_minor: 285000, line_total_minor: 570000, available: 7 }
                  subtotal_minor: 570000
                  shipping_minor: 15000
                  total_minor: 585000
                  service: { code: standard, label: Standard delivery }
                  estimated_delivery: { min_days: 2, max_days: 4, text: "2–4 days" }
                  payment_options:
                    - { id: cash_on_delivery, kind: cash_on_delivery, label: Cash on delivery }
                    - { id: wallet_qr, kind: online, label: Pay by QR }
                problems: []
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: "`delivery_unavailable`: the store does not deliver to this destination"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { error: { code: delivery_unavailable, message: "We do not deliver to this address." } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders:
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create an order (idempotent)
      description: |
        Create the order atomically, in one transaction: re-check prices and
        stock, decrement or reserve stock, insert the order and store the
        idempotency result.
        - **Price changed:** a line's `unit_price_minor` differs from the live
          price → `409 price_changed`.
        - **Stock:** a line is unknown, inactive or short on stock →
          `409 out_of_stock`.
        - **Totals:** totals that don't add up → `400 invalid_request`.
        - **Payment option:** `payment.method` is not one of the store's
          payment option ids, or not allowed for this total →
          `422 payment_method_unavailable`.
        - **Test orders** (`test: true`): same validation, recorded as a test,
          no stock change, payment, courier or notification.

        **Stores with `shipping.quotes`** require `shipping_quote_id`
        (missing → `400 invalid_request`). `shipping_minor` must equal the
        quote's, and the lines and `shipping_address` must be the ones quoted.
        - **Expired quote:** `409 quote_expired`.
        - **Different lines, destination or shipping, an unknown quote, or a
          quote already used by another order:** `409 quote_mismatch`.

        **Online payment options** (`payments.sessions`): the order is
        created with `payment_status: pending`, `payment_method` and
        `payment_required: true`. Don't ship it until it is paid. Whether
        stock is reserved or decremented meanwhile is the store's choice,
        but the store must never oversell.

        **Idempotency.** Store the key per connection for at least 24 hours.
        - **Exact replay:** the same key and body return the stored status
          and body, with `Idempotent-Replayed: true`.
        - **Still running:** `409 idempotency_in_progress`.
        - **Different body:** `422 idempotency_mismatch`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:write] }]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: "Sent by Setu as `setu:{workspaceId}:{checkoutSessionId}:{attempt}`. Treat it as opaque."
          schema: { type: string, minLength: 8, maxLength: 255, pattern: "^[\\x21-\\x7e]+$" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateOrderRequest" }
            examples:
              cash_on_delivery:
                summary: Base store, cash on delivery
                value:
                  external_reference: ext_cs_9f2a
                  lines:
                    - { product_id: p_42, variant_id: v_42_m_black, quantity: 2, unit_price_minor: 299000 }
                  currency: NPR
                  expected_subtotal_minor: 598000
                  shipping_minor: 15000
                  expected_total_minor: 613000
                  customer: { name: Test Customer, phone: "+9779800000000", email: null }
                  shipping_address: { line1: Near the school, city: Kathmandu, area: Baneshwor, country: NP }
                  payment: { method: cash_on_delivery }
                  channel: instagram
                  note: Please call before delivery
                  source: { platform: setu, channel: instagram, conversation_ref: conv_8Hd2, setu_order_ref: ext_cs_9f2a }
                  test: false
              quoted_online:
                summary: Store with shipping quotes, online payment
                value:
                  external_reference: ext_cs_9f2b
                  lines:
                    - { product_id: p_42, variant_id: v_42_m_black, quantity: 2, unit_price_minor: 285000 }
                  currency: NPR
                  expected_subtotal_minor: 570000
                  shipping_minor: 15000
                  shipping_quote_id: q_7Hk2
                  expected_total_minor: 585000
                  customer: { name: Test Customer, phone: "+9779800000000", email: null }
                  shipping_address: { line1: 12 Example Road, line2: Flat 3, city: Kathmandu, area: Baneshwor, region: Bagmati, postal_code: "44600", country: NP, latitude: 27.6915, longitude: 85.342 }
                  payment: { method: wallet_qr }
                  channel: instagram
                  note: null
                  test: false
      responses:
        "201":
          description: Order created, or a replay of a created order
          headers:
            Idempotent-Replayed:
              description: "`true` when this response is a stored replay"
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "409":
          description: "`price_changed`, `out_of_stock`, `quote_expired`, `quote_mismatch` or `idempotency_in_progress`"
          headers:
            Idempotent-Replayed:
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                price_changed:
                  value:
                    error:
                      code: price_changed
                      message: Prices changed since checkout was validated.
                      details: { lines: [{ product_id: p_42, variant_id: v_42_m_black, unit_price_minor: 319000 }] }
                out_of_stock:
                  value:
                    error:
                      code: out_of_stock
                      message: Some items are no longer available.
                      details: { problems: [{ product_id: p_42, variant_id: v_42_m_black, code: insufficient_stock, available: 1 }] }
                idempotency_in_progress:
                  value: { error: { code: idempotency_in_progress, message: A request with this Idempotency-Key is still being processed. Retry shortly. } }
                quote_expired:
                  value: { error: { code: quote_expired, message: The shipping quote expired. Request a new one. } }
                quote_mismatch:
                  value: { error: { code: quote_mismatch, message: "Lines, destination or shipping_minor differ from the quote." } }
        "422":
          description: "`idempotency_mismatch` (the same key was sent with a different body) or `payment_method_unavailable`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              examples:
                idempotency_mismatch:
                  value: { error: { code: idempotency_mismatch, message: This Idempotency-Key was used with a different body. } }
                payment_method_unavailable:
                  value: { error: { code: payment_method_unavailable, message: This store does not accept that payment option. } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders/{orderId}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Get an order created through this connection
      description: |
        Orders created through another connection return `404`.

        Stores that list `orders.lookup_by_number` also accept the
        customer-facing order `number` in place of the id (URL-encoded).
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:read] }]
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: Order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders/{orderId}/payment-sessions:
    post:
      tags: [Payments]
      operationId: createPaymentSession
      summary: Start an online payment for an order (capability `payments.sessions`)
      description: |
        Only for stores that list `payments.sessions`. The store creates the
        payment with **its own** payment gateway and returns only what the
        customer needs: an https `url` on the store's domain and, optionally,
        a `qr_payload`. Gateway credentials stay with the store; never return
        secrets, card or bank data. A test order (`test: true`) must never
        contact a real gateway.

        - **Body:** optional. `{}` or no body uses the order's
          `payment_method`; `method` may name another online option.
        - **Idempotency:** `Idempotency-Key` is required and scoped per
          connection like `POST /orders`. The same key and body replay the
          stored response. The order id is part of the request identity.
        - **One live session per order:** while a session is still
          `pending`, a new key returns that same session instead of starting
          a second payment.

        Errors:
        - Unknown order (or another connection's): `404 not_found`.
        - Already paid, cancelled, or a cash-on-delivery order:
          `409 payment_not_allowed`.
        - `method` is not an online payment option of this store:
          `422 payment_method_unavailable`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:write] }]
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - name: Idempotency-Key
          in: header
          required: true
          description: "Sent by Setu as `setu:{workspaceId}:{checkoutSessionId}:pay:{n}`. Treat it as opaque."
          schema: { type: string, minLength: 8, maxLength: 255, pattern: "^[\\x21-\\x7e]+$" }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePaymentSessionRequest" }
            example: { method: wallet_qr }
      responses:
        "201":
          description: Payment session created, the still-pending session, or a replay
          headers:
            Idempotent-Replayed:
              description: "`true` when this response is a stored replay"
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentSessionResponse" }
              example:
                payment_session:
                  id: ps_4Tq9
                  order_id: o_1002
                  method: wallet_qr
                  status: pending
                  amount_minor: 585000
                  currency: NPR
                  url: https://shop.example.com/pay/ps_4Tq9
                  qr_payload: "EXAMPLEPAY:ps_4Tq9:585000"
                  expires_at: "2026-10-12T08:40:00.000Z"
                  updated_at: "2026-10-12T08:30:00.000Z"
                  test: false
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`payment_not_allowed` (already paid, cancelled or cash on delivery) or `idempotency_in_progress`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { error: { code: payment_not_allowed, message: This order does not need a payment. } }
        "422":
          description: "`payment_method_unavailable` or `idempotency_mismatch`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders/{orderId}/cancel:
    post:
      tags: [Orders]
      operationId: cancelOrder
      summary: Cancel an order (capability `orders.cancel`)
      description: |
        The store cancels the order and applies its own stock, payment and
        delivery rules (for example, returns reserved stock once). Allowed
        while the order is `pending`, `confirmed` or `processing` and not
        `paid` or `refunded`; otherwise `409 invalid_transition`. Paid orders
        go through the store's own refund process.

        Common rules for all order actions:
        - **Idempotency:** `Idempotency-Key` is required. Store the outcome,
          including a `409` refusal, for 24 hours and replay it with
          `Idempotent-Replayed: true`. Never repeat a side effect (restock,
          email, status history) on a replay. The same key for another
          action, order or body is `422 idempotency_mismatch`.
        - **Concurrency:** check, change and record in one transaction with
          the order locked. If `expected_status` or `expected_updated_at` no
          longer match, answer `409 order_changed` and change nothing.
        - **Response:** the updated order, with a new `updated_at` and
          `allowed_actions`. Send `order.updated` if a webhook is registered.
        - **Visibility:** orders created through another connection return
          `404 not_found`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:write] }]
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/OrderActionIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderActionRequest" }
            example: { expected_status: confirmed, expected_updated_at: "2026-10-12T08:15:30.000Z", reason: Customer asked to cancel }
      responses:
        "200":
          description: Done, or a replay of the stored answer
          headers:
            Idempotent-Replayed:
              description: "`true` when this response is a stored replay"
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
              example:
                order:
                  id: o_1001
                  number: EX-1001
                  status: cancelled
                  payment_status: unpaid
                  payment_method: cash_on_delivery
                  currency: NPR
                  subtotal_minor: 185000
                  shipping_minor: 15000
                  total_minor: 200000
                  external_reference: setu_ord_8f2c
                  allowed_actions: []
                  created_at: "2026-10-12T08:00:00.000Z"
                  updated_at: "2026-10-12T08:16:02.000Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`invalid_transition`, `order_changed` or `idempotency_in_progress`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { error: { code: invalid_transition, message: This order can no longer be changed that way. } }
        "422":
          description: "`idempotency_mismatch`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders/{orderId}/fulfil:
    post:
      tags: [Orders]
      operationId: fulfilOrder
      summary: Mark an order fulfilled (capability `orders.fulfil`)
      description: |
        The store moves the order to its "sent to the customer" state,
        usually `shipped`. Allowed while the order is `confirmed` or
        `processing` and is cash on delivery or already `paid`; otherwise
        `409 invalid_transition`. `reason` is not accepted.

        Common rules for all order actions:
        - **Idempotency:** `Idempotency-Key` is required. Store the outcome,
          including a `409` refusal, for 24 hours and replay it with
          `Idempotent-Replayed: true`. Never repeat a side effect (restock,
          email, status history) on a replay. The same key for another
          action, order or body is `422 idempotency_mismatch`.
        - **Concurrency:** check, change and record in one transaction with
          the order locked. If `expected_status` or `expected_updated_at` no
          longer match, answer `409 order_changed` and change nothing.
        - **Response:** the updated order, with a new `updated_at` and
          `allowed_actions`. Send `order.updated` if a webhook is registered.
        - **Visibility:** orders created through another connection return
          `404 not_found`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:write] }]
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/OrderActionIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderActionRequest" }
            example: { expected_status: confirmed, expected_updated_at: "2026-10-12T08:15:30.000Z" }
      responses:
        "200":
          description: Done, or a replay of the stored answer
          headers:
            Idempotent-Replayed:
              description: "`true` when this response is a stored replay"
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
              example:
                order:
                  id: o_1001
                  number: EX-1001
                  status: shipped
                  payment_status: unpaid
                  payment_method: cash_on_delivery
                  currency: NPR
                  subtotal_minor: 185000
                  shipping_minor: 15000
                  total_minor: 200000
                  external_reference: setu_ord_8f2c
                  allowed_actions: [record_cash_payment]
                  created_at: "2026-10-12T08:00:00.000Z"
                  updated_at: "2026-10-12T08:16:02.000Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`invalid_transition`, `order_changed` or `idempotency_in_progress`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { error: { code: invalid_transition, message: This order can no longer be changed that way. } }
        "422":
          description: "`idempotency_mismatch`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/orders/{orderId}/cash-payment:
    post:
      tags: [Orders]
      operationId: recordCashPayment
      summary: Record a cash-on-delivery payment (capability `orders.cash_payment`)
      description: |
        The store records that the cash-on-delivery amount was collected:
        `payment_status` becomes `paid`. **Never for online payments:** any
        order not paid by cash on delivery gets `409
        payment_method_mismatch`; online payments are confirmed only by the
        payment provider. Cash is recorded once: an order that is already
        `paid` or `cancelled` gets `409 invalid_transition`. `reason` is not
        accepted.

        Common rules for all order actions:
        - **Idempotency:** `Idempotency-Key` is required. Store the outcome,
          including a `409` refusal, for 24 hours and replay it with
          `Idempotent-Replayed: true`. Never repeat a side effect (restock,
          email, status history) on a replay. The same key for another
          action, order or body is `422 idempotency_mismatch`.
        - **Concurrency:** check, change and record in one transaction with
          the order locked. If `expected_status` or `expected_updated_at` no
          longer match, answer `409 order_changed` and change nothing.
        - **Response:** the updated order, with a new `updated_at` and
          `allowed_actions`. Send `order.updated` if a webhook is registered.
        - **Visibility:** orders created through another connection return
          `404 not_found`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:write] }]
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/OrderActionIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderActionRequest" }
            example: { expected_status: confirmed, expected_updated_at: "2026-10-12T08:15:30.000Z" }
      responses:
        "200":
          description: Done, or a replay of the stored answer
          headers:
            Idempotent-Replayed:
              description: "`true` when this response is a stored replay"
              schema: { const: "true" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResponse" }
              example:
                order:
                  id: o_1001
                  number: EX-1001
                  status: shipped
                  payment_status: paid
                  payment_method: cash_on_delivery
                  currency: NPR
                  subtotal_minor: 185000
                  shipping_minor: 15000
                  total_minor: 200000
                  external_reference: setu_ord_8f2c
                  allowed_actions: []
                  created_at: "2026-10-12T08:00:00.000Z"
                  updated_at: "2026-10-12T08:16:02.000Z"
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`payment_method_mismatch`, `invalid_transition`, `order_changed` or `idempotency_in_progress`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
              example: { error: { code: invalid_transition, message: This order can no longer be changed that way. } }
        "422":
          description: "`idempotency_mismatch`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/payment-sessions/{paymentSessionId}:
    get:
      tags: [Payments]
      operationId: getPaymentSession
      summary: Current status of a payment session (capability `payments.sessions`)
      description: |
        Re-checks the payment with the store's gateway and returns the
        current status. Setu polls this with backoff until the status is
        final (`paid`, `failed`, `expired`, `cancelled`) or the session
        expires, and after every `payment.updated` webhook. Sessions of
        another connection return `404`.
      security: [{ apiKeyBearer: [] }, { setuBearer: [orders:read] }]
      parameters:
        - $ref: "#/components/parameters/PaymentSessionId"
      responses:
        "200":
          description: Payment session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentSessionResponse" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/webhooks:
    put:
      tags: [Webhooks]
      operationId: putWebhook
      summary: Create or replace this connection's webhook registration
      description: |
        There is one registration per connection. Every call returns a
        **new** `secret`; from then on, sign only with it.
      security: [{ setuBearer: [webhooks:manage] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookRegistrationRequest" }
            example:
              url: https://setu.vantelyx.dev/api/webhooks/store/rk_example
              events: [product.updated, product.deleted, inventory.updated, order.updated, app.uninstalled]
      responses:
        "200":
          description: Registration
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookRegistration" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete the registration and stop deliveries
      security: [{ setuBearer: [webhooks:manage] }]
      responses:
        "204": { description: "Deleted, or there was nothing to delete" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

  /api/setu/v1/webhooks/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: |
        Returns a new secret. Sign with both the new and the previous secret
        until `previous_secret_expires_at` (at most 24 hours).
      security: [{ setuBearer: [webhooks:manage] }]
      responses:
        "200":
          description: New secret
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RotateSecretResponse" }
        "401": { $ref: "#/components/responses/InvalidToken" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "5XX": { $ref: "#/components/responses/ServerError" }

webhooks:
  storeEvent:
    post:
      tags: [Webhooks]
      operationId: deliverStoreEvent
      summary: Store → Setu event delivery
      description: |
        The store POSTs to the exact `url` from `PUT /webhooks`, e.g.
        `https://setu.vantelyx.dev/api/webhooks/store/{routeKey}`.

        **Signature:** `v1 = hex(HMAC-SHA256(secret, "<t>.<raw body>"))`.
        During a rotation, add one `v1=` for each active secret.

        **Setu's checks:**
        - It accepts a delivery if any `v1` matches the current secret, or the
          previous secret during its overlap.
        - It rejects a `t` more than 300 seconds away from its clock.
        - It processes each event id at most once.

        **`payment.updated`** (stores with `payments.sessions`; data
        `{order_id, payment_session_id}`): a hint only. Setu always re-reads
        `GET /payment-sessions/{id}` before acting on it, and keeps polling
        when no webhook arrives.

        **Retries:** retry from an outbox with backoff for up to 24 hours.
        Keep the same event id and body, and re-sign each attempt.
      security: []
      parameters:
        - name: Setu-Signature
          in: header
          required: true
          schema: { type: string, examples: ["t=1791645262,v1=2c795d707a996b4df01c8e34cfa86dbab709af02600387163c12b29a243f3afd"] }
        - name: Setu-Event-Id
          in: header
          required: true
          description: Must equal the body's `id`.
          schema: { $ref: "#/components/schemas/EventId" }
        - name: Setu-Event-Type
          in: header
          required: true
          schema: { $ref: "#/components/schemas/WebhookEventType" }
      requestBody:
        required: true
        description: At most 64 KB.
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example: { id: evt_test_1, type: product.updated, created_at: "2026-10-10T08:15:31.000Z", data: { product_id: p_42 } }
      responses:
        "200":
          description: Processed, or a duplicate of an event already processed
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { const: true }
                  duplicate: { const: true }
        "400": { description: "Bad JSON, an unknown event type, or the header and body ids differ. Don't retry." }
        "401": { description: "Signature missing, malformed or wrong, or the timestamp is outside tolerance" }
        "404": { description: Unknown route key. Setu disconnected; stop sending. }
        "413": { description: Body over 64 KB }
        "5XX": { description: Retry }

components:
  securitySchemes:
    apiKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: opaque API key
      description: |
        `Authorization: Bearer <API key>`. The key is generated in the store's
        admin (≥256 bits of randomness), shown once, stored only as a hash and
        compared in constant time. Rotating or revoking it makes every request
        with the old key fail with `401 invalid_token`. Grants the
        `capabilities` that `GET /store` lists.
    setuBearer:
      type: oauth2
      description: |
        Authorization code with PKCE S256, public client `client_id=setu`.
        Bearer tokens are scoped to one connection.
      flows:
        authorizationCode:
          authorizationUrl: https://shop.example.com/admin/setu/authorize
          tokenUrl: https://shop.example.com/oauth/token
          refreshUrl: https://shop.example.com/oauth/token
          scopes:
            catalog:read: Read products, categories, variants and images
            inventory:read: Check live prices and stock
            orders:read: Read the status of orders Setu created
            orders:write: Create orders (and payment sessions) from Setu conversations
            webhooks:manage: Notify Setu when products, stock or orders change

  parameters:
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` from the previous page. Opaque.
      schema: { type: string, maxLength: 1024 }
    Limit:
      name: limit
      in: query
      description: Page size. Values above 100 are clamped to 100.
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    UpdatedSince:
      name: updated_since
      in: query
      description: RFC 3339 timestamp. Returns rows with `updated_at ≥` this value.
      schema: { type: string, format: date-time }
    ProductId:
      name: productId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Id" }
    OrderId:
      name: orderId
      in: path
      required: true
      description: "The order `id`. With `orders.lookup_by_number`, `GET /orders/{orderId}` also accepts the order `number`."
      schema: { $ref: "#/components/schemas/Id" }
    OrderActionIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: "Sent by Setu as `setu:{workspaceId}:{orderId}:{action}:{order version}`, the same for a retry of the same click. Treat it as opaque."
      schema: { type: string, minLength: 8, maxLength: 255, pattern: "^[\\x21-\\x7e]+$" }
    PaymentSessionId:
      name: paymentSessionId
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Id" }

  responses:
    InvalidRequest:
      description: "`invalid_request`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
          example: { error: { code: invalid_request, message: "lines[0].quantity: must be an integer from 1 to 20", details: { field: "lines[0].quantity" } } }
    InvalidToken:
      description: "`invalid_token`: the token is missing, unknown, expired or revoked"
      headers:
        WWW-Authenticate:
          schema: { const: 'Bearer error="invalid_token"' }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
          example: { error: { code: invalid_token, message: "The access token is missing, expired or revoked." } }
    InsufficientScope:
      description: "`insufficient_scope`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
          example: { error: { code: insufficient_scope, message: This token lacks the orders:write scope., details: { required: orders:write } } }
    NotFound:
      description: "`not_found`"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    RateLimited:
      description: "`rate_limited`: limit each key to about 120 requests per minute. Setu honours `Retry-After`."
      headers:
        Retry-After:
          description: Seconds to wait
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
          example: { error: { code: rate_limited, message: Too many requests., details: { retry_after: 2 } } }
    RateLimitedOAuth:
      description: Too many requests
      headers:
        Retry-After:
          schema: { type: integer, minimum: 1 }
    ServerError:
      description: "`server_error` (500) or `unavailable` (503). Setu retries."
      headers:
        Retry-After:
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }

  schemas:
    Id:
      type: string
      minLength: 1
      maxLength: 128
    EventId:
      type: string
      pattern: "^[A-Za-z0-9_.:-]{1,128}$"
    Timestamp:
      type: string
      format: date-time
    Money:
      description: Integer count of the currency's minor unit
      type: integer
      minimum: 0
    Currency:
      description: ISO 4217
      type: string
      pattern: "^[A-Z]{3}$"
    Scope:
      type: string
      enum: [catalog:read, inventory:read, orders:read, orders:write, webhooks:manage]
    WebhookEventType:
      type: string
      enum: [product.updated, product.deleted, inventory.updated, order.updated, payment.updated, app.uninstalled]
      description: "`payment.updated` is only registered with stores that list `payments.sessions`."
    PaymentMethodId:
      type: string
      pattern: "^[a-z0-9][a-z0-9_.-]{0,63}$"
      description: "A payment option id. `cash_on_delivery` is reserved for cash on delivery."
      examples: [cash_on_delivery, wallet_qr]

    DiscoveryDocument:
      type: object
      required: [setu_commerce_version, store_name, api_base, authorize_url, token_url, revoke_url, scopes_supported]
      properties:
        setu_commerce_version: { const: "1" }
        store_name: { type: string, minLength: 1, maxLength: 200 }
        api_base: { type: string, format: uri, description: No trailing slash }
        authorize_url: { type: string, format: uri }
        token_url: { type: string, format: uri }
        revoke_url: { type: string, format: uri }
        scopes_supported:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
          description: Must contain all five scopes

    AuthorizationCodeGrant:
      type: object
      required: [grant_type, code, redirect_uri, code_verifier, client_id]
      properties:
        grant_type: { const: authorization_code }
        code: { type: string }
        redirect_uri: { type: string, format: uri }
        code_verifier: { type: string, pattern: "^[A-Za-z0-9._~-]{43,128}$" }
        client_id: { const: setu }
    RefreshTokenGrant:
      type: object
      required: [grant_type, refresh_token, client_id]
      properties:
        grant_type: { const: refresh_token }
        refresh_token: { type: string }
        client_id: { const: setu }

    StoreRef:
      type: object
      required: [id, name, currency]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        name: { type: string, minLength: 1, maxLength: 200 }
        currency: { $ref: "#/components/schemas/Currency" }

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, refresh_token, scope, store]
      properties:
        access_token: { type: string, minLength: 16, maxLength: 2048 }
        token_type: { const: Bearer }
        expires_in:
          type: integer
          minimum: 60
          maximum: 2678400
          description: Access-token lifetime in seconds. 3600 is recommended.
        refresh_token:
          type: string
          minLength: 16
          maxLength: 2048
          description: A new refresh token on every grant
        scope: { type: string, description: Space-separated granted scopes. Setu requires all five. }
        store: { $ref: "#/components/schemas/StoreRef" }
      example:
        access_token: sat_example
        token_type: Bearer
        expires_in: 3600
        refresh_token: srt_example
        scope: catalog:read inventory:read orders:read orders:write webhooks:manage
        store: { id: store_123, name: Example Store, currency: NPR }

    TokenErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope]
        error_description: { type: string }

    StoreInfo:
      allOf:
        - $ref: "#/components/schemas/StoreRef"
        - type: object
          required: [website_url, timezone, payment_methods, shipping_quotes]
          properties:
            website_url: { type: [string, "null"], format: uri }
            timezone: { type: [string, "null"], description: "IANA time zone, e.g. Asia/Kathmandu" }
            payment_methods:
              type: array
              minItems: 1
              items: { $ref: "#/components/schemas/PaymentMethodId" }
              description: |
                Ids of the payment options Setu may use. Base stores list
                `cash_on_delivery`. With `payments.sessions`, list the same ids
                as `payment_options`.
            payment_options:
              type: array
              items: { $ref: "#/components/schemas/PaymentOption" }
              description: Required with `payments.sessions`. Optional otherwise.
            shipping_quotes:
              type: boolean
              description: True when `/checkout/validate` returns `shipping_minor` for an address
            api_version:
              const: "1"
              description: Contract version. Required in API-key mode.
            capabilities:
              type: array
              items: { type: string }
              description: |
                Required in API-key mode. Must include `catalog.read` and
                `orders.write`. Optional: `shipping.quotes`,
                `payments.sessions`, `orders.lookup_by_number`,
                `orders.cancel`, `orders.fulfil`, `orders.cash_payment`,
                `webhooks.manage`. Setu uses an optional capability only when
                it is listed and ignores unknown values.
              examples: [[catalog.read, orders.write, shipping.quotes, payments.sessions]]
            product_count:
              type: [integer, "null"]
              minimum: 0
              description: Optional. Number of products GET /products returns.

    Category:
      type: object
      required: [id, name, parent_id, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        name: { type: string, minLength: 1 }
        parent_id: { type: [string, "null"] }
        updated_at: { $ref: "#/components/schemas/Timestamp" }

    ProductImage:
      type: object
      required: [url, alt, position]
      properties:
        url: { type: string, format: uri, pattern: "^https://", description: "Absolute, publicly loadable https URL" }
        alt: { type: [string, "null"] }
        position: { type: integer, minimum: 0 }

    Variant:
      type: object
      required: [id, title, sku, options, price_minor, compare_at_minor, available, active, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        title: { type: string, description: "e.g. M / Black" }
        sku: { type: [string, "null"] }
        options:
          type: object
          additionalProperties: { type: string }
          description: 'Option name → value, e.g. {"Size":"M","Colour":"Black"}'
        price_minor: { $ref: "#/components/schemas/Money" }
        compare_at_minor:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
        available: { type: integer, minimum: 0 }
        active: { type: boolean }
        updated_at: { $ref: "#/components/schemas/Timestamp" }

    Product:
      type: object
      required: [id, title, description, status, category_ids, tags, images, variants, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        title: { type: string, minLength: 1 }
        description: { type: [string, "null"] }
        status: { type: string, enum: [active, draft, archived] }
        category_ids: { type: array, items: { $ref: "#/components/schemas/Id" } }
        tags: { type: array, items: { type: string } }
        images: { type: array, items: { $ref: "#/components/schemas/ProductImage" } }
        variants: { type: array, items: { $ref: "#/components/schemas/Variant" } }
        url:
          type: [string, "null"]
          format: uri
          pattern: "^https://"
          description: Optional. Product page on the storefront.
        updated_at:
          $ref: "#/components/schemas/Timestamp"
          description: Must change whenever the product, a variant, a price, stock or an image changes
      example:
        id: p_42
        title: Linen Kurta
        description: Breathable linen kurta.
        status: active
        category_ids: [c_tops]
        tags: [linen, summer]
        images: [{ url: "https://cdn.example.com/p_42/1.jpg", alt: Front view, position: 0 }]
        url: https://shop.example.com/products/linen-kurta
        variants:
          - { id: v_42_m_black, title: M / Black, sku: LK-M-BLK, options: { Size: M, Colour: Black }, price_minor: 299000, compare_at_minor: null, available: 4, active: true, updated_at: "2026-10-10T08:15:31.000Z" }
        updated_at: "2026-10-10T08:15:31.000Z"

    ProductResponse:
      type: object
      required: [product]
      properties:
        product: { $ref: "#/components/schemas/Product" }

    CategoryPage:
      type: object
      required: [data, next_cursor]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Category" }, maxItems: 100 }
        next_cursor: { type: [string, "null"] }

    ProductPage:
      type: object
      required: [data, next_cursor]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Product" }, maxItems: 100 }
        next_cursor: { type: [string, "null"] }

    LineInput:
      type: object
      required: [product_id, variant_id, quantity]
      properties:
        product_id: { $ref: "#/components/schemas/Id" }
        variant_id: { $ref: "#/components/schemas/Id" }
        quantity: { type: integer, minimum: 1, maximum: 20 }

    Address:
      type: object
      required: [line1, country]
      properties:
        line1: { type: string, minLength: 1, maxLength: 500 }
        city: { type: [string, "null"], maxLength: 120 }
        area: { type: [string, "null"], maxLength: 120 }
        country: { type: string, pattern: "^[A-Z]{2}$", description: ISO 3166-1 alpha-2 }

    ShippingDestination:
      description: An `Address` with optional detail for delivery pricing.
      allOf:
        - $ref: "#/components/schemas/Address"
        - type: object
          properties:
            line2: { type: [string, "null"], maxLength: 500 }
            region: { type: [string, "null"], maxLength: 120, description: "State, province or district" }
            postal_code: { type: [string, "null"], maxLength: 32 }
            latitude: { type: number, minimum: -90, maximum: 90 }
            longitude: { type: number, minimum: -180, maximum: 180 }
          dependentRequired:
            latitude: [longitude]
            longitude: [latitude]

    PaymentOption:
      type: object
      required: [id, kind, label]
      properties:
        id: { $ref: "#/components/schemas/PaymentMethodId" }
        kind:
          type: string
          enum: [cash_on_delivery, online]
          description: "`cash_on_delivery`: pay the courier. `online`: pay through a payment session before the order ships."
        label: { type: string, minLength: 1, description: "Customer-facing name, e.g. Pay by QR" }
        description: { type: [string, "null"] }
        min_total_minor:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: Order totals below this cannot use the option
        max_total_minor:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: Order totals above this cannot use the option

    ShippingQuoteRequest:
      type: object
      required: [lines, destination]
      properties:
        lines:
          type: array
          minItems: 1
          maxItems: 15
          description: Each (product_id, variant_id) at most once
          items: { $ref: "#/components/schemas/LineInput" }
        destination: { $ref: "#/components/schemas/ShippingDestination" }

    ShippingQuote:
      type: object
      required: [id, expires_at, currency, lines, subtotal_minor, shipping_minor, total_minor, service, estimated_delivery, payment_options]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        expires_at:
          $ref: "#/components/schemas/Timestamp"
          description: 15 minutes ahead is recommended; at most 24 hours
        currency: { $ref: "#/components/schemas/Currency" }
        lines: { type: array, items: { $ref: "#/components/schemas/PricedLine" } }
        subtotal_minor: { $ref: "#/components/schemas/Money" }
        shipping_minor: { $ref: "#/components/schemas/Money" }
        total_minor:
          $ref: "#/components/schemas/Money"
          description: subtotal_minor + shipping_minor
        service:
          type: object
          required: [code, label]
          properties:
            code: { type: string }
            label: { type: string }
        estimated_delivery:
          oneOf:
            - type: object
              required: [min_days, max_days, text]
              properties:
                min_days: { type: [integer, "null"], minimum: 0 }
                max_days: { type: [integer, "null"], minimum: 0 }
                text: { type: [string, "null"] }
            - type: "null"
        payment_options:
          type: array
          items: { $ref: "#/components/schemas/PaymentOption" }
          description: Payment options available for this quote's total

    ShippingQuoteResponse:
      type: object
      required: [quote, problems]
      properties:
        quote:
          oneOf:
            - $ref: "#/components/schemas/ShippingQuote"
            - type: "null"
          description: Null when any line has a problem
        problems: { type: array, items: { $ref: "#/components/schemas/LineProblem" } }

    ValidateRequest:
      type: object
      required: [lines]
      properties:
        lines:
          type: array
          minItems: 1
          maxItems: 15
          description: Each (product_id, variant_id) at most once
          items: { $ref: "#/components/schemas/LineInput" }
        shipping_address:
          oneOf:
            - $ref: "#/components/schemas/Address"
            - type: "null"

    PricedLine:
      allOf:
        - $ref: "#/components/schemas/LineInput"
        - type: object
          required: [title, variant_title, unit_price_minor, line_total_minor, available]
          properties:
            title: { type: string }
            variant_title: { type: string }
            unit_price_minor: { $ref: "#/components/schemas/Money" }
            line_total_minor: { $ref: "#/components/schemas/Money" }
            available: { type: integer, minimum: 0 }

    LineProblem:
      type: object
      required: [product_id, variant_id, code]
      properties:
        product_id: { type: string }
        variant_id: { type: string }
        code: { type: string, enum: [not_found, inactive, insufficient_stock] }
        available: { type: integer, minimum: 0, description: "Present for `insufficient_stock`" }

    ValidateResponse:
      type: object
      required: [currency, lines, subtotal_minor, shipping_minor, problems]
      properties:
        currency: { $ref: "#/components/schemas/Currency" }
        lines: { type: array, items: { $ref: "#/components/schemas/PricedLine" } }
        subtotal_minor: { $ref: "#/components/schemas/Money" }
        shipping_minor:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: Delivery fee for `shipping_address`, or null when the store doesn't quote shipping
        problems: { type: array, items: { $ref: "#/components/schemas/LineProblem" } }

    OrderLineInput:
      allOf:
        - $ref: "#/components/schemas/LineInput"
        - type: object
          required: [unit_price_minor]
          properties:
            unit_price_minor:
              $ref: "#/components/schemas/Money"
              description: The price Setu showed the customer. A mismatch with the live price gives `price_changed`.

    CreateOrderRequest:
      type: object
      required: [external_reference, lines, currency, expected_subtotal_minor, shipping_minor, expected_total_minor, customer, shipping_address, payment, channel, note]
      properties:
        external_reference:
          type: string
          minLength: 1
          maxLength: 128
          description: Setu's order id. Unique per connection.
        lines:
          type: array
          minItems: 1
          maxItems: 15
          items: { $ref: "#/components/schemas/OrderLineInput" }
        currency: { $ref: "#/components/schemas/Currency" }
        expected_subtotal_minor:
          $ref: "#/components/schemas/Money"
          description: Must equal Σ unit_price_minor × quantity
        shipping_minor:
          $ref: "#/components/schemas/Money"
          description: With `shipping.quotes`, must equal the quote's `shipping_minor`
        shipping_quote_id:
          $ref: "#/components/schemas/Id"
          description: Required by stores that list `shipping.quotes`; ignored otherwise. The quote must match the lines and `shipping_address` and not be expired or used.
        discount_minor:
          $ref: "#/components/schemas/Money"
          description: Optional, default 0. Whole-order discount; must not exceed the subtotal.
        tax_minor:
          $ref: "#/components/schemas/Money"
          description: Optional, default 0. Tax added on top (omit when prices include tax).
        expected_total_minor:
          $ref: "#/components/schemas/Money"
          description: Must equal expected_subtotal_minor + shipping_minor − discount_minor + tax_minor
        customer:
          type: object
          required: [name, phone, email]
          properties:
            name: { type: string, minLength: 1, maxLength: 120 }
            phone: { type: string, minLength: 1, maxLength: 32 }
            email: { type: [string, "null"], maxLength: 254 }
        shipping_address: { $ref: "#/components/schemas/ShippingDestination" }
        payment:
          type: object
          required: [method]
          properties:
            method:
              $ref: "#/components/schemas/PaymentMethodId"
              description: One of the store's payment option ids. Unknown ids get `422 payment_method_unavailable`.
        channel: { type: string, enum: [instagram, messenger, whatsapp, panel] }
        note: { type: [string, "null"], maxLength: 1000 }
        source:
          type: object
          description: Optional. Where the order came from; record it on the order.
          required: [platform, channel, setu_order_ref]
          properties:
            platform: { const: setu }
            channel: { type: string, enum: [instagram, messenger, whatsapp, panel] }
            conversation_ref: { type: [string, "null"], maxLength: 128 }
            setu_order_ref: { type: string, maxLength: 128 }
        test:
          type: boolean
          default: false
          description: |
            Test order. Validate it like a real order and record it clearly
            marked as a test, but do not deduct stock, capture payment, book a
            courier or notify the customer.

    Order:
      type: object
      required: [id, number, status, payment_status, currency, subtotal_minor, shipping_minor, total_minor, external_reference, created_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        number: { type: string, description: The order number shown in the store's admin }
        status: { type: string, enum: [pending, confirmed, processing, shipped, delivered, cancelled] }
        payment_status:
          type: string
          enum: [unpaid, pending, paid, refunded, failed]
          description: "`pending`: waiting for an online payment"
        payment_method:
          $ref: "#/components/schemas/PaymentMethodId"
          description: Optional. The payment option id of the order.
        payment_required:
          type: boolean
          description: Optional. True while an online payment is still needed before the order ships.
        currency: { $ref: "#/components/schemas/Currency" }
        subtotal_minor: { $ref: "#/components/schemas/Money" }
        shipping_minor: { $ref: "#/components/schemas/Money" }
        discount_minor: { $ref: "#/components/schemas/Money", description: "Optional, default 0" }
        tax_minor: { $ref: "#/components/schemas/Money", description: "Optional, default 0" }
        total_minor: { $ref: "#/components/schemas/Money" }
        external_reference: { type: [string, "null"] }
        test: { type: boolean, default: false, description: "True for orders created with `test: true`" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
        allowed_actions:
          type: array
          items: { type: string, enum: [cancel, fulfil, record_cash_payment] }
          description: |
            Optional, for stores with an order-action capability. The actions
            this order allows right now under the store's rules. Setu shows a
            button only for listed actions; the endpoints still enforce the
            rules themselves.
        payment_review:
          oneOf:
            - { $ref: "#/components/schemas/OrderPaymentReview" }
            - { type: "null" }
          description: |
            Optional. Present while money on the order needs a person's review,
            for example a payment received after the order was cancelled. The
            store records the payment, keeps the order cancelled and sends an
            empty `allowed_actions` until the review is resolved in its admin.

    OrderPaymentReview:
      type: object
      required: [required, reason]
      properties:
        required: { type: boolean, description: "True while the review is open." }
        reason:
          type: string
          enum: [paid_after_cancellation, cancelled_after_payment, other]
        since: { $ref: "#/components/schemas/Timestamp" }

    OrderActionRequest:
      type: object
      description: All fields are optional; an empty body or no body is valid.
      properties:
        expected_status:
          type: string
          enum: [pending, confirmed, processing, shipped, delivered, cancelled]
          description: The status Setu last saw. A different current status is `409 order_changed`.
        expected_updated_at:
          $ref: "#/components/schemas/Timestamp"
          description: The `updated_at` Setu last saw. A different current value is `409 order_changed`.
        reason:
          type: string
          maxLength: 200
          description: Cancel only. A short note for the store's records. Rejected with `400 invalid_request` on fulfil and cash-payment.

    OrderResponse:
      type: object
      required: [order]
      properties:
        order: { $ref: "#/components/schemas/Order" }

    WebhookRegistrationRequest:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, maxLength: 2048, pattern: "^https://" }
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }

    WebhookRegistration:
      type: object
      required: [id, url, events, secret, created_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: "#/components/schemas/WebhookEventType" } }
        secret: { type: string, minLength: 16, description: Signing secret. Store it encrypted. }
        created_at: { $ref: "#/components/schemas/Timestamp" }

    RotateSecretResponse:
      type: object
      required: [secret, previous_secret_expires_at]
      properties:
        secret: { type: string, minLength: 16 }
        previous_secret_expires_at:
          $ref: "#/components/schemas/Timestamp"
          description: At most 24 hours after the rotation

    WebhookEvent:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { $ref: "#/components/schemas/EventId" }
        type: { $ref: "#/components/schemas/WebhookEventType" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
        data:
          type: object
          description: |
            Contents by event type:
            - `product.*` and `inventory.updated`: `product_id`
            - `order.updated`: `order_id`
            - `payment.updated`: `order_id`, `payment_session_id`
            - `app.uninstalled`: `{}`
          properties:
            product_id: { $ref: "#/components/schemas/Id" }
            order_id: { $ref: "#/components/schemas/Id" }
            payment_session_id: { $ref: "#/components/schemas/Id" }

    ErrorBody:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_request, invalid_token, insufficient_scope, not_found, price_changed, out_of_stock, quote_expired, quote_mismatch, payment_not_allowed, invalid_transition, order_changed, payment_method_mismatch, delivery_unavailable, payment_method_unavailable, idempotency_in_progress, idempotency_mismatch, rate_limited, server_error, unavailable]
            message: { type: string }
            details: { type: object, additionalProperties: true }

    PaymentSession:
      type: object
      required: [id, order_id, method, status, amount_minor, currency, url, qr_payload, expires_at, updated_at]
      properties:
        id: { $ref: "#/components/schemas/Id" }
        order_id: { $ref: "#/components/schemas/Id" }
        method: { $ref: "#/components/schemas/PaymentMethodId" }
        status:
          type: string
          enum: [pending, paid, failed, expired, cancelled]
          description: "`pending` until the gateway reports a final status"
        amount_minor: { $ref: "#/components/schemas/Money" }
        currency: { $ref: "#/components/schemas/Currency" }
        url:
          type: [string, "null"]
          format: uri
          pattern: "^https://"
          description: Payment page on the store's own domain. Setu relays it to the customer.
        qr_payload:
          type: [string, "null"]
          description: Optional raw QR payload the customer can scan
        expires_at: { $ref: "#/components/schemas/Timestamp" }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
        test: { type: boolean, description: "True for sessions of test orders. These never contact a real gateway." }

    CreatePaymentSessionRequest:
      type: object
      description: Optional body. Omit `method` to use the order's payment option.
      properties:
        method: { $ref: "#/components/schemas/PaymentMethodId" }

    PaymentSessionResponse:
      type: object
      required: [payment_session]
      properties:
        payment_session: { $ref: "#/components/schemas/PaymentSession" }
