openapi: 3.1.0
info:
  title: Sellix Public API
  version: 1.1.0
  description: Public API for products, orders, invoices, customers, payouts, webhook endpoints, and API keys.
servers:
  - url: https://api.sellix.gg/v1
security:
  - bearerAuth: []
paths:
  /products:
    get:
      operationId: listProducts
      summary: List products
      x-required-scope: products:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/ProductStatus'
        - name: type
          in: query
          schema:
            $ref: '#/components/schemas/ProductType'
      responses:
        '200':
          description: Cursor-paginated product list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      operationId: createProduct
      summary: Create a product
      x-required-scope: products:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductCreate'
      responses:
        '201':
          description: Product created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /products/{id}:
    parameters:
      - $ref: '#/components/parameters/ProductId'
    get:
      operationId: getProduct
      summary: Fetch a product
      x-required-scope: products:read
      responses:
        '200':
          description: Product details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateProduct
      summary: Update a product
      x-required-scope: products:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductUpdate'
      responses:
        '200':
          description: Updated product.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
    delete:
      operationId: archiveProduct
      summary: Archive a product
      x-required-scope: products:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Product archived.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletedResource'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /products/{id}/keys:
    post:
      operationId: importProductKeys
      summary: Import account credentials
      description: Only account products use an imported credential pool. License-key products generate keys automatically.
      x-required-scope: products:write
      parameters:
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys]
              properties:
                keys:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    type: string
                    maxLength: 10000
      responses:
        '201':
          description: Credential import result.
          content:
            application/json:
              schema:
                type: object
                required: [imported, duplicates, stock_after]
                properties:
                  imported: { type: integer, minimum: 0 }
                  duplicates: { type: integer, minimum: 0 }
                  stock_after: { type: integer, minimum: 0 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /orders:
    get:
      operationId: listOrders
      summary: List orders
      x-required-scope: orders:read
      parameters: &orderListParameters
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/OrderStatus'
        - name: from
          in: query
          schema: { type: string }
          description: ISO-8601 lower bound for created_at.
        - name: to
          in: query
          schema: { type: string }
          description: ISO-8601 upper bound for created_at.
      responses: &orderListResponses
        '200':
          description: Cursor-paginated order list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /orders/{uuid}:
    get:
      operationId: getOrder
      summary: Fetch an order
      x-required-scope: orders:read
      parameters: &orderDetailParameters
        - $ref: '#/components/parameters/OrderUuid'
      responses: &orderDetailResponses
        '200':
          description: Order with line items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderDetail'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /orders/{uuid}/refund:
    post:
      operationId: refundOrder
      summary: Refund an order in full
      description: Partial refunds are not supported. amount_cents, when supplied, must equal the order total.
      x-required-scope: orders:write
      parameters:
        - $ref: '#/components/parameters/OrderUuid'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                amount_cents: { type: integer, minimum: 1 }
                reason:
                  type: string
                  enum: [requested_by_buyer, duplicate, fraud, other]
                note: { type: [string, 'null'], maxLength: 2000 }
      responses:
        '200':
          description: Persisted refund state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Refund'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '502': { $ref: '#/components/responses/ProviderError' }
  /orders/{uuid}/resend:
    post:
      operationId: resendOrderEmail
      summary: Resend the confirmation or delivery email
      x-required-scope: orders:write
      parameters:
        - $ref: '#/components/parameters/OrderUuid'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Email accepted by the configured mail transport.
          content:
            application/json:
              schema:
                type: object
                required: [sent_at, recipient, message_type]
                properties:
                  sent_at: { type: string, format: date-time }
                  recipient: { type: string, format: email }
                  message_type: { type: string, enum: [confirmation, delivery] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '502': { $ref: '#/components/responses/ProviderError' }
  /invoices:
    get:
      operationId: listInvoices
      summary: List invoices
      description: Compatibility alias for GET /orders. Returns the same order objects.
      x-required-scope: orders:read
      parameters: *orderListParameters
      responses: *orderListResponses
  /invoices/{uuid}:
    get:
      operationId: getInvoice
      summary: Fetch an invoice
      description: Compatibility alias for GET /orders/{uuid}.
      x-required-scope: orders:read
      parameters: *orderDetailParameters
      responses: *orderDetailResponses
  /customers:
    get:
      operationId: listCustomers
      summary: List customers
      x-required-scope: customers:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Cursor-paginated customer list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /customers/{id}:
    get:
      operationId: getCustomer
      summary: Fetch a customer
      x-required-scope: customers:read
      parameters:
        - $ref: '#/components/parameters/CustomerId'
      responses:
        '200':
          description: Customer metrics and up to 100 recent orders.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerDetail'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /payouts:
    get:
      operationId: listPayouts
      summary: List payouts
      x-required-scope: payouts:read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/PayoutStatus'
      responses:
        '200':
          description: Cursor-paginated payout list. Destination addresses are omitted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
    post:
      operationId: createPayout
      summary: Submit a BlockPN payout
      description: amount is a decimal string in asset units, not fiat cents.
      x-required-scope: payouts:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutCreate'
      responses:
        '201':
          description: Payout accepted by BlockPN and persisted locally.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutCreateResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '502': { $ref: '#/components/responses/ProviderError' }
  /webhooks:
    get:
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      x-required-scope: webhooks:read
      responses:
        '200':
          description: Webhook endpoint list. Signing secrets are never returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreate'
      responses:
        '201':
          description: Webhook endpoint and one-time signing secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointCreated'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /webhooks/{id}/test:
    post:
      operationId: testWebhookEndpoint
      summary: Queue a signed test event
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  $ref: '#/components/schemas/WebhookEvent'
      responses:
        '202':
          description: Test event queued for normal delivery and retries.
          content:
            application/json:
              schema:
                type: object
                required: [queued, event_id, delivery_id]
                properties:
                  queued: { type: boolean, const: true }
                  event_id: { type: string, format: uuid }
                  delivery_id: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /webhooks/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      x-required-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Webhook endpoint deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletedResource'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
  /api-keys:
    get:
      operationId: listApiKeys
      summary: List API key metadata
      x-required-scope: admin
      responses:
        '200':
          description: API key metadata without plaintext secrets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyList'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createApiKey
      summary: Create an API key
      x-required-scope: admin
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiKeyCreate'
      responses:
        '201':
          description: API key with one-time plaintext secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyCreated'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /api-keys/{id}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      x-required-scope: admin
      parameters:
        - $ref: '#/components/parameters/ApiKeyId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: API key revoked.
          content:
            application/json:
              schema:
                type: object
                required: [id, revoked]
                properties:
                  id: { type: string, pattern: '^apik_[a-z0-9]+$' }
                  revoked: { type: boolean, const: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_ followed by 40 alphanumeric characters
  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      description: Opaque next_cursor value from the previous response.
      schema: { type: string, maxLength: 2048 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique key for this mutation. Exact replays return the stored response for 24 hours.
      schema: { type: string, minLength: 8, maxLength: 255 }
    ProductId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^prod_[a-z0-9]+$' }
    CustomerId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^cus_[a-z0-9]+$' }
    WebhookId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^wh_[a-z0-9]+$' }
    ApiKeyId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^apik_[a-z0-9]+$' }
    OrderUuid:
      name: uuid
      in: path
      required: true
      schema: { type: string, format: uuid }
  schemas:
    ProductType:
      type: string
      enum: [license_key, account, file_download, service, dynamic, physical]
    ProductStatus:
      type: string
      enum: [draft, active, hidden, archived]
    Product:
      type: object
      required: [object, id, name, slug, type, price_cents, currency, stock, status, created_at, updated_at]
      properties:
        object: { type: string, const: product }
        id: { type: string, pattern: '^prod_[a-z0-9]+$' }
        name: { type: string }
        slug: { type: string }
        type: { $ref: '#/components/schemas/ProductType' }
        description: { type: [string, 'null'] }
        price_cents: { type: integer, minimum: 0 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        stock: { type: [integer, 'null'], minimum: 0 }
        status: { $ref: '#/components/schemas/ProductStatus' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ProductCreate:
      type: object
      required: [name, type, price_cents, currency]
      properties:
        name: { type: string, minLength: 2, maxLength: 120 }
        type: { $ref: '#/components/schemas/ProductType' }
        price_cents: { type: integer, minimum: 0 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        slug: { type: string, minLength: 2, maxLength: 80 }
        description: { type: [string, 'null'], maxLength: 20000 }
        status: { $ref: '#/components/schemas/ProductStatus' }
        stock: { type: [integer, 'null'], minimum: 0 }
        dynamic_webhook_url: { type: [string, 'null'], format: uri }
    ProductUpdate:
      type: object
      minProperties: 1
      properties:
        name: { type: string, minLength: 2, maxLength: 120 }
        slug: { type: string, minLength: 2, maxLength: 80 }
        price_cents: { type: integer, minimum: 0 }
        description: { type: [string, 'null'], maxLength: 20000 }
        status: { $ref: '#/components/schemas/ProductStatus' }
        stock: { type: [integer, 'null'], minimum: 0 }
        dynamic_webhook_url: { type: [string, 'null'], format: uri }
    ProductList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Product' }
    OrderStatus:
      type: string
      enum: [pending, awaiting_payment, paid, delivering, completed, failed, expired, cancelled, refunded]
    OrderSummary:
      type: object
      required: [object, uuid, status, customer_email, total_cents, currency, created_at, paid_at]
      properties:
        object: { type: string, const: order }
        uuid: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        customer_email: { type: string, format: email }
        total_cents: { type: integer, minimum: 0 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        created_at: { type: string, format: date-time }
        paid_at: { type: [string, 'null'], format: date-time }
        email: { type: string, format: email, deprecated: true }
        total: { type: integer, minimum: 0, deprecated: true }
    OrderDetail:
      allOf:
        - $ref: '#/components/schemas/OrderSummary'
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                type: object
                properties:
                  product_id: { type: integer }
                  product_name: { type: string }
                  quantity: { type: integer, minimum: 1 }
                  unit_price_cents: { type: integer, minimum: 0 }
                  subtotal_cents: { type: integer, minimum: 0 }
    OrderList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/OrderSummary' }
    Refund:
      type: object
      required: [object, id, order_id, status, amount_cents, currency, created_at]
      properties:
        object: { type: string, const: refund }
        id: { type: string, pattern: '^rf_[0-9a-f-]+$' }
        order_id: { type: string, format: uuid }
        status: { type: string, enum: [processing, submitted, succeeded, failed, requires_review] }
        amount_cents: { type: integer, minimum: 1 }
        currency: { type: string, minLength: 3, maxLength: 3 }
        reason: { type: [string, 'null'] }
        note: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
    Customer:
      type: object
      required: [object, id, email, orders_count, lifetime_cents]
      properties:
        object: { type: string, const: customer }
        id: { type: string, pattern: '^cus_[a-z0-9]+$' }
        email: { type: string, format: email }
        orders_count: { type: integer, minimum: 0 }
        lifetime_cents: { type: integer, minimum: 0 }
    CustomerDetail:
      allOf:
        - $ref: '#/components/schemas/Customer'
        - type: object
          properties:
            name: { type: [string, 'null'] }
            country: { type: [string, 'null'] }
            first_seen_at: { type: [string, 'null'], format: date-time }
            last_seen_at: { type: [string, 'null'], format: date-time }
            orders: { type: array, maxItems: 100, items: { type: object } }
    CustomerList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Customer' }
    PayoutStatus:
      type: string
      enum: [processing, pending, broadcast, confirmed, failed, cancelled]
    Payout:
      type: object
      required: [object, id, status, chain, asset, amount, created_at]
      properties:
        object: { type: string, const: payout }
        id: { type: string, pattern: '^po_[a-z0-9]+$' }
        status: { $ref: '#/components/schemas/PayoutStatus' }
        chain: { type: string }
        asset: { type: string }
        amount: { type: string, pattern: '^(?=.*[1-9])(?:0|[1-9]\d{0,17})(?:\.\d{1,18})?$' }
        tx_hash: { type: string }
        created_at: { type: string, format: date-time }
        submitted_at: { type: string, format: date-time }
        confirmed_at: { type: string, format: date-time }
    PayoutCreate:
      type: object
      required: [chain, asset, amount, destination_address]
      properties:
        chain: { type: string, minLength: 2, maxLength: 32 }
        asset: { type: string, minLength: 2, maxLength: 16 }
        amount: { type: string, pattern: '^(?=.*[1-9])(?:0|[1-9]\d{0,17})(?:\.\d{1,18})?$' }
        destination_address: { type: string, minLength: 8, maxLength: 255 }
        memo: { type: [string, 'null'], maxLength: 255 }
    PayoutCreateResponse:
      allOf:
        - $ref: '#/components/schemas/Payout'
        - type: object
          required: [destination_address]
          properties:
            destination_address: { type: string }
    PayoutList:
      allOf:
        - $ref: '#/components/schemas/ListEnvelope'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Payout' }
    WebhookEvent:
      type: string
      enum: [order.paid, order.failed, order.refunded, delivery.completed, delivery.failed]
    WebhookEndpoint:
      type: object
      required: [object, id, url, events, status, created_at]
      properties:
        object: { type: string, const: webhook_endpoint }
        id: { type: string, pattern: '^wh_[a-z0-9]+$' }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: [string, 'null'] }
        status: { type: string, enum: [active, disabled] }
        created_at: { type: string, format: date-time }
    WebhookEndpointCreate:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, maxLength: 500 }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: [string, 'null'], maxLength: 255 }
    WebhookEndpointCreated:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          required: [signing_secret]
          properties:
            signing_secret: { type: string, pattern: '^whsec_[A-Za-z0-9]{40}$' }
    WebhookEndpointList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/WebhookEndpoint' }
    ApiAbility:
      type: string
      description: Granular abilities are required for new resources. Legacy read/write values retain Orders-only compatibility; admin grants every ability.
      enum: [read, write, admin, products:read, products:write, orders:read, orders:write, customers:read, payouts:read, payouts:write, webhooks:read, webhooks:write]
    ApiKey:
      type: object
      required: [object, id, name, prefix, abilities, rate_limit_per_minute, created_at]
      properties:
        object: { type: string, const: api_key }
        id: { type: string, pattern: '^apik_[a-z0-9]+$' }
        name: { type: string }
        prefix: { type: string }
        abilities:
          type: array
          items: { $ref: '#/components/schemas/ApiAbility' }
        rate_limit_per_minute: { type: integer, minimum: 10, maximum: 600 }
        last_used_at: { type: [string, 'null'], format: date-time }
        expires_at: { type: [string, 'null'], format: date-time }
        revoked_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
    ApiKeyCreate:
      type: object
      required: [name, abilities]
      properties:
        name: { type: string, maxLength: 80 }
        abilities:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: '#/components/schemas/ApiAbility' }
        expires_in_days: { type: integer, minimum: 1, maximum: 90, default: 90 }
        rate_limit_per_minute: { type: integer, minimum: 10, maximum: 600, default: 60 }
    ApiKeyCreated:
      allOf:
        - $ref: '#/components/schemas/ApiKey'
        - type: object
          required: [secret]
          properties:
            secret: { type: string, pattern: '^sk_[A-Za-z0-9]{40}$' }
    ApiKeyList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/ApiKey' }
    ListEnvelope:
      type: object
      required: [object, has_more, next_cursor, data]
      properties:
        object: { type: string, const: list }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
        data: { type: array, items: {} }
    DeletedResource:
      type: object
      required: [id, deleted]
      properties:
        id: { type: string }
        deleted: { type: boolean, const: true }
    ApiErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, message, trace_id]
          properties:
            type: { type: string }
            message: { type: string }
            trace_id: { type: string, format: uuid }
            details: { type: object, additionalProperties: true }
  responses:
    BadRequest:
      description: Missing or malformed Idempotency-Key header.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    Unauthenticated:
      description: Missing, invalid, expired, or revoked API key.
      headers:
        WWW-Authenticate: { schema: { type: string, const: Bearer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    Forbidden:
      description: API key lacks the required scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    NotFound:
      description: Resource does not exist within the key's tenant and shop scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    Conflict:
      description: Resource state conflict or idempotency key reuse with different request data.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    Unprocessable:
      description: Request validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    RateLimited:
      description: Per-key or route rate limit exceeded.
      headers:
        Retry-After: { schema: { type: integer, minimum: 1 } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
    ProviderError:
      description: An upstream payout, refund, or mail provider rejected the operation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiErrorEnvelope' }
