# GENERATED FILE. Do not edit by hand.
#
# Regenerate with:
#   go test ./internal/server/ -run TestOpenAPISpecIsUpToDate -update
#
# Derived from publishedV1Routes and the frozen response goldens, so this file
# cannot describe a route or a shape the server does not actually serve.
openapi: 3.1.0
info:
  title: Mercemur Public API
  version: "1"
  description: >-
    Access to a merchant's commerce data. Authenticate with a secret API key;
    every route requires the scope named on it, and a write scope never implies
    its read, so an integration that needs both directions asks for both.
    DELETE IS A SEPARATE GRANT from the write that creates and updates: a
    route whose scope reads write_<resource>:delete is refused to a key holding
    write_<resource> alone, so a key that keeps records up to date cannot
    destroy them by accident. The qualified grant cannot be issued on its own
    either, since it narrows the write rather than replacing it, so ask the
    merchant for both write_<resource> and write_<resource>:delete when your
    integration deletes. Only the resources with a DELETE in this document
    publish the qualified grant at all.
    Lists are cursor-paginated: pass the previous response's page.next_cursor as
    ?after. Money is an integer in the currency's minor unit, always accompanied
    by a currency code. Every write REQUIRES an Idempotency-Key header and is
    refused with 400 idempotency_key_required without one; replaying a key
    returns the first response byte for byte.
servers:
  - url: https://api.mercemur.com
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        A secret API key. Publishable keys cannot reach this API. A key may
        carry an expiry, and an expired key is refused exactly like an unknown
        one, with a 401 that names no reason; check the key's expires_at in the
        dashboard rather than inferring it from a response. When a merchant
        rolls a key's secret they choose a grace window of up to 3 days, and
        for its duration BOTH the new secret and the one it replaced
        authenticate, so an integration moves over on its own deploy schedule
        instead of at the instant the button is pressed. Move before the window
        closes: after it, the old secret is refused. Nothing else about this
        contract moves with a roll. The key keeps its id and its scopes, so the
        only thing an integration updates is the credential itself.
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: >-
        The same secret key as apiKey, sent as a bare header instead of a Bearer
        credential. It carries IDENTICAL authority: same store, same scopes, same
        lifecycle. It exists for clients and gateways that cannot set an
        Authorization header. Send ONE of the two; when both arrive,
        Authorization wins and this header is not read.
  schemas:
    Error:
      type: object
      required: [error]
      description: |-
        The body of EVERY refusal on this API, at every status. One shape, so a
        client writes one parser and one branch rather than one per route.

        BRANCH ON `code`, NEVER ON `message`. The message is English prose for a
        human reading a log and its wording is not part of this contract.

        THIS SHAPE IS EVERYTHING THIS API WRITES, including the 404 for a path
        that is not mounted and the 405 for a method a mounted path does not
        serve. A response that is NOT this shape did not come from this API: it
        came from the proxy in front of it, and the status is the only thing in
        it worth reading.
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |-
                A snake_case token naming the condition. The set is open: new
                routes bring new codes, so switch with a default arm.

                ONE CODE MEANS ONE THING AT ONE STATUS. A code never appears at
                two different statuses, so a client switching on the code and a
                client switching on the status cannot disagree.

                The 404 family is the one place the spelling is noise. Routes that
                shipped before the rule answer with a per-entity spelling
                (`order_not_found`, `product_not_found`) and newer ones answer
                `not_found`; BOTH carry `resource`, which is the half to branch on.
            message:
              type: string
              description: >-
                What went wrong, in English, for a human. Safe to log and safe to
                show to a developer. Not safe to branch on and not localised.
            resource:
              type: string
              description: >-
                The kind of thing that was missing or unconfigured, snake_case
                singular ("order", "license_key_pool"). Present on a 404, on a 422
                and on `not_configured`. On a nested url it names the LAST entity the
                path addressed, never the parent, so a caller who asked for a
                contact under a company that exists is told the contact is missing
                rather than debugging the company id.
            field:
              type: string
              description: >-
                The request field at fault, in your own naming ("customer_id",
                "items[].variant_id"). Present when exactly one field is to blame.
                Absent when the failure is about the request as a whole; absence is
                not a claim that no field was involved.
            reason:
              type: string
              enum: ["malformed_json", "body_too_large", "body_unreadable", "missing_field", "unknown_field", "read_only_field", "null_field", "wrong_type", "out_of_range", "empty_patch", "invalid_value"]
              description: |-
                Present on `invalid_body`, which covers several distinct failures.
                This partitions it into a CLOSED set you may switch on
                exhaustively, which is what makes retryability decidable: a
                `body_unreadable` is worth resending and an `unknown_field` never is.
    WebhookTopic:
      type: string
      enum: ["*", "customers/created", "customers/deleted", "customers/updated", "fulfillments/created", "fulfillments/updated", "gift_cards/adjusted", "gift_cards/disabled", "gift_cards/enabled", "gift_cards/expired", "gift_cards/issued", "gift_cards/redeemed", "inventory/updated", "orders/cancelled", "orders/fulfilled", "orders/paid", "orders/refunded", "products/created", "products/deleted", "products/updated", "reviews/created", "reviews/updated"]
      description: |-
        An event type an endpoint can subscribe to.

        SUBSCRIBING COSTS TWO GRANTS. `write_webhooks` reaches the endpoint
        routes themselves; each topic is then checked against its own
        family's read scope, so an endpoint receiving `orders/paid` needs
        `read_orders` as well. The 403 names the scope that is missing.

        - `customers/created` requires `read_customers`
        - `customers/deleted` requires `read_customers`
        - `customers/updated` requires `read_customers`
        - `fulfillments/created` requires `read_fulfillment`
        - `fulfillments/updated` requires `read_fulfillment`
        - `gift_cards/adjusted` requires `read_gift_cards`
        - `gift_cards/disabled` requires `read_gift_cards`
        - `gift_cards/enabled` requires `read_gift_cards`
        - `gift_cards/expired` requires `read_gift_cards`
        - `gift_cards/issued` requires `read_gift_cards`
        - `gift_cards/redeemed` requires `read_gift_cards`
        - `inventory/updated` requires `read_inventory`
        - `orders/cancelled` requires `read_orders`
        - `orders/fulfilled` requires `read_orders`
        - `orders/paid` requires `read_orders`
        - `orders/refunded` requires `read_orders`
        - `products/created` requires `read_products`
        - `products/deleted` requires `read_products`
        - `products/updated` requires `read_products`
        - `reviews/created` requires `read_reviews`
        - `reviews/updated` requires `read_reviews`

        `*` subscribes to every topic listed above, including ones added
        after you subscribe, and therefore requires every read scope named
        here rather than the ones your key happens to hold.

        A few events the platform dispatches internally sit outside this
        taxonomy and cannot be subscribed to with an API key. They are absent
        from this list rather than refused after you send them.
    WebhookEventPayload:
      type: object
      required: [schema_version, event, occurred_at, data]
      description: |-
        The body of a webhook delivery. Every topic uses this envelope, so one
        parser handles all of them and routing is a switch on `event` rather
        than on which handler received the request.

        COMPATIBILITY. Changes are additive: new fields may appear inside
        `data` at any time, so parse leniently and ignore what you do not
        recognise. A field is never removed or retyped under the same
        `schema_version`; a change that would break a receiver arrives as a
        bumped `schema_version`, which is what makes branching on it possible
        instead of guessing from the fields present.

        There is deliberately no id field in the body. The delivery id travels
        as the webhook-id header and is the value to deduplicate on.
      properties:
        schema_version:
          type: string
          const: "1"
          description: >-
            The payload contract version. Branch on it rather than on field
            presence; a receiver written for one version cannot be assumed to
            handle the next.
        event:
          type: string
          description: >-
            The topic that fired, one of the values in WebhookTopic other than
            the `*` wildcard, which subscribes but never appears on the wire.
            It is also sent as the webhook-event-type header, so a receiver can
            route before parsing.
        occurred_at:
          type: string
          format: date-time
          description: >-
            When the event happened, UTC. NOT when the delivery was sent: a
            retry hours later carries the original value, because the payload
            is a snapshot of the moment rather than a fresh read.
        data:
          type: object
          description: >-
            The resource the event is about, snapshotted when the event
            occurred. Its fields depend on `event`; see the examples on the
            delivery request below, which are the frozen bodies this platform
            actually sends.
  responses:
    BadRequest:
      description: |-
        The request was refused before any state changed. `code` is one of:

        `invalid_body`, a write body this route will not take. `reason` partitions it
        and `field` names the key when one key is at fault.
        `invalid_query`, a query parameter, including limit and after.
        `invalid_text`, a NUL byte or bytes that are not valid UTF-8 anywhere in the
        path, the query or the body. Strip control characters before sending.
        `idempotency_key_required`, a write sent without the Idempotency-Key header.
        `invalid_request`, an Idempotency-Key longer than 255 bytes.

        Routes add their own codes for rules only they know. Switch with a default arm.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: |-
        No credential, or one this API does not accept. `code` is always
        `unauthorized`.

        THE BODY IS DELIBERATELY UNINFORMATIVE. An expired key, a revoked key, a
        publishable key, a key belonging to another merchant and a key that never
        existed are all refused with the same bytes, so this response cannot be used to
        probe which keys exist. Check the key's state in the dashboard rather than
        inferring it here.

        Send the key as `Authorization: Bearer <key>` or as `X-API-Key: <key>`. It is
        never accepted in a query string.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: |-
        The resource this url addresses does not exist, or never did on this store.

        `resource` names the kind of thing that was missing and is the half to branch
        on: the code is `not_found` on newer routes and a per-entity spelling on
        routes that shipped before that rule, and the two mean the same thing.

        A 404 is ALWAYS about the url. An id inside the request body that resolves to
        nothing is 422 `unprocessable_reference` instead, so the two cases stay
        distinguishable. An id that belongs to a different merchant reads exactly like
        one that never existed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: |-
        The request is well formed and the store's current state refuses it.

        EVERY WRITE CAN ANSWER TWO OF THESE, whatever it does:
        `idempotency_key_reused`, this Idempotency-Key already served a different
        method, path or body. The key namespace is per store and not per endpoint, so a
        key built from a business id collides across routes; use a fresh key per
        logical write.
        `idempotency_key_in_progress`, an identical request is still running. Retry
        after a short delay; exactly one of the racing calls will have applied.

        `not_configured` means the merchant has not set something up yet, with
        `resource` naming what. The remaining codes are the per-route state rules named
        on the operation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableReference:
      description: |-
        An id INSIDE the request resolves to nothing on this store. `code` is
        `unprocessable_reference`, `resource` names the kind of thing that did not
        resolve and `field` names where it arrived, so the fix is mechanical.

        Distinct from 404, which is about the url, and from 400, which is about the
        bytes. The message stays generic about WHY the id did not resolve: an id owned
        by another merchant must read identically to one that never existed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: |-
        The request body exceeded the cap named in the message. `code` is
        `payload_too_large`. Nothing was read and nothing was recorded, so the same
        Idempotency-Key may be reused once the body is smaller.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnsupportedMediaType:
      description: |-
        The body was not declared as `application/json`. `code` is
        `unsupported_media_type`.

        This API reads ONE media type. The `+json` structured suffixes are refused
        too, because they name semantics (merge-patch in particular) this API does
        not implement, and reading such a body as plain JSON would be a silent
        misreading. A write that carries NO body needs no Content-Type at all.
      headers:
        Accept-Post:
          schema: { type: string }
          description: The media type to send instead, on a POST.
        Accept-Patch:
          schema: { type: string }
          description: The media type to send instead, on a PATCH.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |-
        Too many requests. `code` is `rate_limited`.

        Two limits apply independently: one on the credential and the route family,
        one on the client address. The headers describe whichever has less left, so
        honouring Retry-After always clears the window that bound.
      headers:
        Retry-After:
          schema: { type: integer, minimum: 1 }
          description: >-
            Whole seconds to wait before retrying. Rounded up and never 0,
            so honouring it always clears the window.
        RateLimit-Limit:
          schema: { type: integer }
          description: Requests allowed in the window of the limit that bound.
        RateLimit-Remaining:
          schema: { type: integer, minimum: 0 }
          description: >-
            Requests left in that window. Also sent on successful responses,
            where it is the number to pace against. When two limits apply the
            headers describe whichever has less left.
        RateLimit-Reset:
          schema: { type: integer, minimum: 1 }
          description: >-
            Whole seconds until that window resets. A delta, not a timestamp,
            so it does not depend on your clock agreeing with ours.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unexpected:
      description: |-
        Any status this operation does not list, in the same envelope.

        A 5xx means the request may or may not have applied. Retry it with the SAME
        Idempotency-Key: that is the only way to find out without risking a duplicate,
        and it is what the key is for.

        A few 4xx conditions arrive here rather than as a listed status because they
        depend on the merchant's plan or on a module being wired: 402 when a quota or a
        plan limit is reached, and 503 when a capability the route needs is not
        configured on this deployment. Both carry a `code` naming which.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
security:
  - apiKey: []
  - apiKeyHeader: []
paths:
  /api/v1/agent-config:
    get:
      summary: Retrieve agent config
      description: >-
        How this store's assistant is configured: whether it runs at all, its name,
        greeting and persona, the two instruction directives, the three skill groups,
        and the merchant's starter chips.
        A SINGLETON, so there is no id and no page block.
        A STORE THAT HAS NEVER CONFIGURED THE ASSISTANT IS NOT A 404. It answers the
        documented defaults, which are what the assistant is actually running with:
        enabled true, no custom voice, both directives and all three skills on. Its
        updated_at is null, because there is no row to have a version, and that null is
        what you send back as expected_updated_at on your first write.
        enabled is the whole gate. The storefront widget renders on exactly this, so
        false means shoppers see nothing whatever else is set.
        persona_instructions is published here and withheld from the storefront's own
        shopper-facing config. It shapes tone only: the server always appends its safety
        rules on top, so a persona can never remove a guardrail.
      operationId: get_agent_config
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agent_name:
                        type: string
                      enabled:
                        type: boolean
                      follow_up_questions:
                        type: boolean
                      greeting:
                        type: string
                      handoff_message:
                        type: string
                      objection_handling:
                        type: boolean
                      persona_instructions:
                        type: string
                      skill_add_to_cart:
                        type: boolean
                      skill_order_status:
                        type: boolean
                      skill_product_discovery:
                        type: boolean
                      suggested_prompts:
                        type: array
                        items:
                          {}
                      updated_at:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace the store's assistant configuration
      operationId: put_agent_config
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_sales_agent
      x-required-scopes: [write_sales_agent]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled, agent_name, greeting, persona_instructions, follow_up_questions, objection_handling, handoff_message, skill_product_discovery, skill_add_to_cart, skill_order_status, suggested_prompts, expected_updated_at]
              additionalProperties: false
              description: >-
                REPLACES the whole config, and every field is required for that reason.
                An omitted enabled would decode to false and take the assistant off the
                merchant's storefront; an omitted skill_add_to_cart would take its cart
                tools away mid-conversation. Both are 200s a client would never notice.
                Read /api/v1/agent-config and send every field back.
                A store that has never configured the assistant has no row; this creates
                it. There is no DELETE, because removing the row restores the defaults,
                which have the assistant ON, so deleting to disable would do the opposite
                of what it looks like.
              properties:
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. Required, because the config is a
                    singleton written by this route AND by the merchant's dashboard: a
                    write naming no version silently reverts whatever moved under it and
                    answers 200 to both parties. A mismatch is 409 config_modified. null
                    asserts that this store has never saved a config, which is the only
                    way a first write can name a version that does not exist, and it is
                    refused once a row does exist.
                enabled:
                  type: boolean
                  description: >-
                    The master switch. The storefront widget renders on exactly this, so
                    false means shoppers see nothing whatever else is set.
                agent_name:
                  type: string
                  description: The assistant's display name, at most 60 characters. "" for the default.
                greeting:
                  type: string
                  description: The opening line, at most 280 characters. "" for the default.
                persona_instructions:
                  type: string
                  description: >-
                    The merchant's voice, at most 2000 characters. It shapes TONE only:
                    the server appends its safety rules on top, so a persona can never
                    remove a guardrail.
                follow_up_questions:
                  type: boolean
                  description: Switches on a server-authored block that asks clarifying questions.
                objection_handling:
                  type: boolean
                  description: Switches on a server-authored block that answers hesitation.
                handoff_message:
                  type: string
                  description: >-
                    What the shopper is told when a conversation goes to a human, at most
                    280 characters. "" for the built-in text.
                skill_product_discovery:
                  type: boolean
                  description: >-
                    Gates search, recommendation and product detail together. GROUPED
                    rather than one toggle per tool, because an assistant that could
                    recommend a product but not detail it is half broken in a way no
                    single switch names.
                skill_add_to_cart:
                  type: boolean
                  description: Gates reading and writing the shopper's cart, together.
                skill_order_status:
                  type: boolean
                  description: Gates order lookup.
                suggested_prompts:
                  type: array
                  items: { type: string }
                  description: >-
                    The merchant's starter chips. Send [] for none; the key is required
                    either way, because dropping it would clear them. NORMALISED RATHER
                    THAN REFUSED on write: at most six, each truncated to 80 characters,
                    blanks dropped. Read the response back rather than assuming what you
                    sent was stored verbatim.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agent_name:
                        type: string
                      enabled:
                        type: boolean
                      follow_up_questions:
                        type: boolean
                      greeting:
                        type: string
                      handoff_message:
                        type: string
                      objection_handling:
                        type: boolean
                      persona_instructions:
                        type: string
                      skill_add_to_cart:
                        type: boolean
                      skill_order_status:
                        type: boolean
                      skill_product_discovery:
                        type: boolean
                      suggested_prompts:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-conversations:
    get:
      summary: List agent conversations
      description: >-
        Every chat a shopper has had with this store's AI assistant, newest first.
        ONE LIST COVERS THE WHOLE INBOX. The dashboard shows active by default, hides
        closed, and offers a third "all" mode; here that is ?status=, and omitting it
        returns everything.
        PAGED BY CREATION TIME, NOT BY updated_at, even though the dashboard sorts on
        updated_at. Every reply, takeover, assignment and status change rewrites that
        column, so a client walking the list while shoppers are chatting would skip
        conversations that jumped ahead of its position and repeat ones that fell
        behind it. created_at never moves.
        THE TRANSCRIPT IS NOT EMBEDDED. A conversation's message count is unbounded, so
        an inline array would be a page with no cursor. Read
        /api/v1/agent-conversations/{conversationId}/messages.
        external_id is deliberately not published. It is the shopper's WhatsApp or
        Instagram handle, and you can already answer them through the messages route,
        which delivers on their channel AND records the reply in the merchant's own
        transcript.
      operationId: get_agent_conversations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, closed] }
          description: >-
            active is the merchant's working inbox, closed is the archive. Omit it for
            both, which the dashboard can only ask for as status=all. A value outside
            this set is a 400 rather than an ignored filter, because a closed
            conversation served as part of an unfiltered page reads as one still
            waiting on a reply.
        - name: handoff_state
          in: query
          required: false
          schema: { type: string, enum: [bot, human] }
          description: >-
            human is the queue that matters operationally: the assistant has stopped
            answering those and a real person is waiting.
        - name: channel
          in: query
          required: false
          schema: { type: string, enum: [website, whatsapp, instagram] }
          description: >-
            Where the shopper reached this store. It also decides where a reply is
            delivered.
        - name: assigned_to
          in: query
          required: false
          schema: { type: string }
          description: >-
            One teammate's conversations, by store user id. The id is CHECKED against the
            store roster: a user who is not a member is a 400, not an empty page, because
            an empty page is indistinguishable from "this teammate has no conversations"
            and a filter naming a renamed or offboarded teammate would otherwise report no
            work forever. An EMPTY value is a 400 rather than "no filter": a client
            building the query from a variable it forgot to set would otherwise receive
            the whole inbox and read it as one person's workload. There is no way to ask
            for the unassigned ones here.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Conversations touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the whole list. Supplying it CHANGES THE ORDER of this endpoint:
            the default walk descends created_at, and this one climbs updated_at, because that is
            the only ordering in which a keyset walk over a column every reply rewrites cannot skip
            a row. A conversation that changes mid-walk moves forward past your position and is
            returned again, so the walk is AT LEAST ONCE: apply the state of each row you receive
            and a repeat costs nothing. A cursor from one ordering is refused by the other, since
            the same position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        assigned_to:
                          type: [string, "null"]
                        channel:
                          type: string
                        created_at:
                          type: string
                        currency:
                          type: string
                        customer_id:
                          nullable: true
                        display_name:
                          type: [string, "null"]
                        email:
                          type: [string, "null"]
                        handoff_state:
                          type: string
                        id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-conversations/{conversationId}:
    get:
      summary: Retrieve an agent conversation
      description: >-
        One conversation, without its messages. A conversation id that is not this
        store's is a 404.
      operationId: get_agent_conversations_by_conversationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      assigned_to:
                        type: [string, "null"]
                      channel:
                        type: string
                      created_at:
                        type: string
                      currency:
                        type: string
                      customer_id:
                        nullable: true
                      display_name:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      handoff_state:
                        type: string
                      id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a conversation's status, handoff and owner
      operationId: put_agent_conversations_by_conversationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_sales_agent
      x-required-scopes: [write_sales_agent]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, handoff_state, assigned_to]
              additionalProperties: false
              description: >-
                REPLACES all three editable columns, and every one is required for that
                reason. This is the single route for what the dashboard splits into four
                buttons (take over, release, assign, close), because all four write these
                same three columns and nothing else. Splitting them made a client that
                wanted to close a thread and hand it back to the assistant issue two
                writes, with a window in between where the conversation was closed and
                still marked human, which is the state where nobody is answering.
                THE TRANSCRIPT IS NOT WRITABLE HERE. Post a reply at
                /api/v1/agent-conversations/{conversationId}/messages.
                channel, email and customer_id are refused by name: the shopper decides
                all three, and rewriting channel would send every later reply to the
                wrong platform.
              properties:
                status:
                  type: string
                  enum: [active, closed]
                  description: >-
                    closed is BOOKKEEPING, not deletion. The transcript, the owner and
                    the handoff state all survive it and reopening restores the
                    conversation to the merchant's inbox. It is the reversible archive
                    this family offers instead of a destructive delete, and there is no
                    delete on a conversation at all.
                handoff_state:
                  type: string
                  enum: [bot, human]
                  description: >-
                    Who is REPLYING. human means the assistant STOPS ANSWERING that
                    shopper: nothing replies until a person writes one, so setting it is
                    a commitment rather than a label. Setting it does NOT assign the
                    conversation to anybody, which is the one place this differs from the
                    dashboard's take-over button: that button assigns to whoever clicked
                    it, and an api key is not a person.
                assigned_to:
                  type: [string, "null"]
                  description: >-
                    Who OWNS the conversation, by store user id, or null for nobody.
                    Ownership, not takeover. REQUIRED AS A KEY even though its value may
                    be null, because a full replace that dropped it would silently take
                    the teammate who owns a thread off it and answer 200. The id is
                    checked against this store's roster; a user from another store is a
                    400 invalid_assignee.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      assigned_to:
                        type: [string, "null"]
                      channel:
                        type: string
                      created_at:
                        type: string
                      currency:
                        type: string
                      customer_id:
                        nullable: true
                      display_name:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      handoff_state:
                        type: string
                      id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-conversations/{conversationId}/messages:
    get:
      summary: List a conversation's messages
      description: >-
        One conversation's transcript, OLDEST FIRST. This is the only list on this API
        that ascends, and it ascends because a transcript has one legible order: a
        client reading newest-first would have to buffer the whole thread before it
        could render a single exchange in sequence.
        TOOL TURNS ARE NEVER SERVED. The assistant's internal tool calls are how an
        answer was produced rather than part of the conversation, and they are excluded
        in the query so a page is never short.
        role is user for the shopper and assistant for the store. A HUMAN staff reply is
        stored as assistant too, deliberately, so the shopper's widget renders it as a
        message from the store; read author_user_id to tell a person's answer from the
        assistant's.
        rating is the shopper's own 1-5 stars, and null when they have not rated the
        reply. It is never 0.
      operationId: get_agent_conversations_by_conversationId_messages
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_user_id:
                          nullable: true
                        content:
                          type: string
                        conversation_id:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        rating:
                          type: [integer, "null"]
                        role:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Reply to a shopper as the store
      operationId: post_agent_conversations_by_conversationId_messages
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_sales_agent
      x-required-scopes: [write_sales_agent]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              additionalProperties: false
              description: >-
                THIS REACHES A REAL PERSON. A website conversation's reply is polled by
                the storefront widget; a WhatsApp or Instagram one is pushed to the
                shopper on that platform. Send an Idempotency-Key and mean it: a
                duplicate is visible in somebody's phone.
                IT DOES NOT TAKE THE CONVERSATION OVER. Posting while the assistant is
                still answering is legal and is sometimes what a merchant wants, so
                nothing here flips handoff_state. Set it with the PUT above first if you
                mean the assistant to stop.
                role is refused by name. A staff reply is always stored in the assistant
                slot so the shopper's widget renders it as a message from the store, and
                writing a user turn would put words in the shopper's mouth inside the
                merchant's own record.
              properties:
                content:
                  type: string
                  description: >-
                    The message text. Required and refused when blank: an empty reply is
                    delivered to the shopper as an empty message rather than ignored.
                author_user_id:
                  type: [string, "null"]
                  description: >-
                    The teammate this reply is from, by store user id, or null for an
                    unattributed one. Optional, and null is the honest default for an
                    automation: an api key is not a person. Name a real author when you
                    are relaying one, because a shopper's star rating attributes to
                    whoever is named here, and attributing a human's answer to the
                    assistant folds it into the assistant's average. The id is checked
                    against this store's roster.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_user_id:
                        type: [string, "null"]
                      content:
                        type: string
                      conversation_id:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      rating:
                        type: [integer, "null"]
                      role:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-knowledge-sources:
    get:
      summary: List agent knowledge sources
      description: >-
        What the merchant has taught the assistant: FAQ dumps and policy documents,
        newest first.
        THE SOURCE TEXT IS NOT PUBLISHED, here or anywhere. The stored row keeps a title
        and a chunk count; what the assistant answers from is the embedded chunks, and
        no copy of the original is kept to serve. Keep your own copy of anything you
        send.
        chunk_count is how many embedded pieces a source produced, which is the honest
        measure of how much the assistant learned from it. Zero means the ingest found
        nothing usable.
        This list covers only what the MERCHANT added. The store's own catalogue, pages
        and blog posts are indexed by a separate sweep and are not resources here.
      operationId: get_agent_knowledge_sources
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [faq, doc] }
          description: >-
            faq is chunked as question and answer pairs, doc as prose under its title.
            Omit it for both. A value outside this set is a 400 rather than an ignored
            filter.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Sources touched at or after this RFC3339 instant, for reconciling a knowledge corpus
            incrementally instead of re-reading it on every run. It composes with kind. Supplying
            it CHANGES THE ORDER of this endpoint: the default walk descends created_at, and this
            one climbs updated_at. That difference is the whole point here, because updated_at on
            a source moves on RE-INGEST, which is the event you most need to hear about: the
            default walk is keyed on created_at precisely so a re-ingest cannot shuffle a row
            across a boundary you already passed, and the ascending walk is the ordering where
            that same movement is safe, since a row can only move forward past your position and
            be returned again. The walk is AT LEAST ONCE. A cursor from one ordering is refused by
            the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        chunk_count:
                          type: integer
                        created_at:
                          type: string
                        id:
                          type: string
                        kind:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Teach the assistant a source
      operationId: post_agent_knowledge_sources
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_sales_agent
      x-required-scopes: [write_sales_agent]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, title, text]
              additionalProperties: false
              description: >-
                ONE ROUTE FOR BOTH KINDS, where the dashboard has two. The bodies were
                always identical and only the ingest pipeline differed.
                THE TEXT IS NOT STORED. It is embedded into chunks the assistant answers
                from, and the row that comes back keeps a title and a chunk count. There
                is no text property on the resource and no way to read it back, so keep
                your own copy.
                THE EMBED RUNS BEFORE THE ROW LANDS, which is why chunk_count in the 201
                is the real number rather than a promise. A store whose AI stack is not
                enabled answers 503 agent_disabled; that is not your fault and a retry
                after the merchant enables it succeeds.
              properties:
                kind:
                  type: string
                  enum: [faq, doc]
                  description: >-
                    faq is chunked as question and answer pairs, doc as prose under its
                    title. REQUIRED WITH NO DEFAULT: guessing would chunk a policy
                    document as question and answer pairs, and the merchant would find
                    out months later from the answers the assistant gives.
                title:
                  type: string
                  description: How the merchant will recognise this source. At most 200 characters.
                text:
                  type: string
                  description: >-
                    The source material, at most 100000 characters. Ingest input rather
                    than a stored field.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chunk_count:
                        type: integer
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-knowledge-sources/{sourceId}:
    delete:
      summary: Remove a knowledge source
      description: >-
        Remove a knowledge source and the embedded chunks behind it.
        TAKES write_sales_agent:delete ON TOP OF write_sales_agent, and there is no
        reversible twin to offer instead. The row keeps a title and a chunk count and
        never the text, so the merchant's own FAQ or policy wording is gone with it and
        the only copy left is wherever they wrote it originally. What that looks like
        afterwards is worse than an error: the assistant keeps answering, confidently,
        from whatever remains, and a shopper asking about the deleted returns policy
        gets the model's guess instead of the merchant's words.
        204 on success. A source id this store does not own is a 404 rather than a
        silent 204, so a mistyped id cannot read as a removal that never happened.
      operationId: delete_agent_knowledge_sources_by_sourceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_sales_agent:delete
      x-required-scopes: [write_sales_agent, write_sales_agent:delete]
      parameters:
        - name: sourceId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_sales_agent and then on write_sales_agent:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_sales_agent, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve an agent knowledge source
      description: >-
        One knowledge source. A source id that is not this store's is a 404.
      operationId: get_agent_knowledge_sources_by_sourceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: sourceId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chunk_count:
                        type: integer
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/agent-messages:
    get:
      summary: List agent messages
      description: >-
        Every agent message in the store, newest first, each carrying the conversation it
        belongs to.
        THIS IS THE BATCH TRANSCRIPT READ. Without the filter it is a single paged sweep
        across ALL conversations, so reconciling a store's chat history is one walk that
        you group by conversation_id yourself, NOT one request per thread. An audit of
        this API reported a missing batch route on the assumption that transcripts could
        only be read one conversation at a time; they cannot, and this is the route that
        already answers it.
        ?conversation_id= narrows it to a single thread when that is what you want. An
        EMPTY value is refused rather than read as "every conversation", because a client
        building the query from a variable it forgot to set would otherwise receive the
        whole store's transcript and read it as one thread.
        PAIR IT WITH GET /api/v1/agent-conversations, which carries the thread's status,
        assignee and handoff state. This route carries the words; that one carries what
        the thread IS.
      operationId: get_agent_messages
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_sales_agent
      x-required-scopes: [read_sales_agent]
      parameters:
        - name: conversation_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            One conversation's turns. Omit it to read every conversation in the store, which is what
            this endpoint exists for: the per-conversation transcript was the only way to read
            messages, so reconciling N conversations cost 1+N requests against a rate budget this
            whole family shares. An EMPTY value is a 400 rather than "no filter", so a client
            building the query from a variable it forgot to set does not receive the whole store's
            transcript and read it as one thread.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_user_id:
                          nullable: true
                        content:
                          type: string
                        conversation_id:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        rating:
                          type: [integer, "null"]
                        role:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_sales_agent scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-categories:
    get:
      summary: List blog categories
      description: >-
        The store's blog categories: the navigation labels posts are filed under. This is
        what makes the category id inlined on every post dereferenceable, which is the
        reason a bare category_id scalar was refused on the post resource.
        NO VISIBILITY GATE, unlike /blog-posts. A draft post is unreleased editorial; a
        category is a label with no draft state and no status column, so there is nothing
        here to withhold from a key that can already page the posts naming it inline.
        THE INLINE REFERENCE ON A POST IS DELIBERATELY NARROWER and stays that way: it
        carries id, slug and name, and the rest of the category lives here. Publishing the
        whole category in both places would freeze two representations of one
        relationship, and neither could then be withdrawn.
        sort_order IS NOT THE PAGING ORDER. This list pages on (created_at, id), because
        sort_order and name are both rewritten by PATCH /blog-categories/{blogCategoryId}
        and a walk over either drops or repeats a row when an edit crosses a page boundary
        mid-sync. Sort on sort_order client-side.
        THERE IS NO post_count AND NO posts ARRAY. A category with 2,000 posts would
        serialise 2,000 ids into one row of a list, and a count would mean published posts
        under read_blogs and every post under write_blogs, so one field would mean two
        things. Page GET /api/v1/blog-posts instead; every post names its category.
      operationId: get_blog_categories
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_blogs
      x-required-scopes: [read_blogs]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Blog categories touched at or after this RFC3339 instant, for reconciling
            incrementally instead of re-reading the whole list. Supplying it CHANGES THE
            ORDER of this endpoint: the default walk descends created_at, and this one
            climbs updated_at, because that is the only ordering in which a keyset walk over
            a column the merchant keeps rewriting cannot skip a row. A RENAME IS THE COMMON
            CASE here: the slug is what a storefront URL carries and PATCH rewrites it
            freely, so a client that misses one keeps publishing links that no longer
            resolve. A category edited mid-walk moves forward past your position and is
            returned again, so the walk is AT LEAST ONCE: apply the state of each row you
            receive and a repeat costs nothing. The bound is INCLUSIVE, so resuming from the
            newest updated_at you hold re-delivers the rows sharing that instant rather than
            dropping the ones your last page did not reach. A cursor belongs to ONE ordering
            and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        description:
                          type: [string, "null"]
                        id:
                          type: string
                        name:
                          type: string
                        slug:
                          type: string
                        sort_order:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a blog category
      operationId: post_blog_categories
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, name]
              additionalProperties: false
              description: >-
                A navigation label posts are filed under. Creating one publishes nothing
                to a reader on its own: an empty category is invisible on the storefront,
                which is why this write is safe where a post create is deliberately
                draft-only.
                IT COLLIDES RATHER THAN DUPLICATING outside the idempotency window.
                blog_category is UNIQUE (store_id, slug), so a create retried after the
                window answers 409 slug_taken instead of minting a second row. Reconcile
                by listing, not by re-posting.
                The body refuses posts, post_ids and post_count by name: a post names its
                category and not the other way round, so file a post with category_id on
                PATCH /blog-posts/{postId}. It also refuses status, published and
                published_at, because a category has no publish state at all; what a
                reader sees is the published posts filed under it.
              properties:
                slug:
                  type: string
                  description: >-
                    Required. NORMALISED server-side to lowercase letters, digits and
                    single hyphens, so the stored value may differ from what you sent;
                    read it back from the response rather than assuming. Unique per
                    store.
                name:
                  type: string
                  description: Required. Trimmed, and a blank name is a 400.
                description:
                  type: [string, "null"]
                  description: >-
                    Optional. Null and absent mean the same thing here, since there is
                    nothing stored to leave alone.
                sort_order:
                  type: integer
                  description: >-
                    Optional, defaults to 0, which is what every category has until a
                    merchant orders them. It is the storefront widget's display ordering
                    and NOT the order the list pages in: that is (created_at, id),
                    because this field is rewritten by the patch and a walk over it would
                    drop or repeat a row mid-sync. Sort on it client-side.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      slug:
                        type: string
                      sort_order:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-categories/{blogCategoryId}:
    get:
      summary: Retrieve a blog category
      description: >-
        One blog category, keyed on the IMMUTABLE id rather than the slug. The patch route
        rewrites slugs freely, so a slug-addressed route would publish an identifier that
        stops resolving the moment a merchant renames a category.
        THE SAME SHAPE THE LIST SERVES, and the same shape both writes answer with, so a
        row taken off a page can be re-read on its own.
        A 404 IS DELIBERATELY GENERIC and covers three conditions at once: no such id, an
        id belonging to another store, and a malformed id. Separating them would make this
        route an existence oracle over another merchant's category ids.
      operationId: get_blog_categories_by_blogCategoryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_blogs
      x-required-scopes: [read_blogs]
      parameters:
        - name: blogCategoryId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      slug:
                        type: string
                      sort_order:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a blog category
      operationId: patch_blog_categories_by_blogCategoryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: blogCategoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and every value is an absolute target, so
                replaying the same body converges without the idempotency ledger being
                reached. A body naming NO field is a 400: a patch that reported success
                for nothing would let a caller with a misspelled field read a green
                light for a no-op.
                ABSENT, PRESENT AND NULL ARE THREE DIFFERENT REQUESTS on description.
                Absent leaves the stored value alone, null CLEARS it, a value sets it.
                Null on slug or name is a 400 because both columns are NOT NULL, and null
                on sort_order is a 400 for a sharper reason: the service reads it as
                "leave alone", so accepting it would answer 200 having changed nothing.
                PATCH rather than PUT, because created_at is state this body cannot
                express and a whole-resource replacement reading would misdescribe what
                the request authorises.
              properties:
                slug:
                  type: string
                  description: >-
                    Normalised server-side. Renaming it CHANGES THE STOREFRONT URL for
                    this category, and nothing redirects the old one.
                name:
                  type: string
                  description: Trimmed, and a blank name is a 400.
                description:
                  type: [string, "null"]
                  description: Send null to clear it. Omit to leave it alone.
                sort_order:
                  type: integer
                  description: Not nullable here. Omit it to leave the ordering alone.
                expected_updated_at:
                  type: string
                  format: date-time
                  description: >-
                    OPTIONAL precondition. Send back the updated_at you read and the
                    write is refused with 409 category_modified if the category moved
                    since, instead of silently reverting whoever edited it in between.
                    Omit it to keep last-write-wins. It is compared at the precision
                    you send, so a millisecond timestamp satisfies a microsecond one.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      slug:
                        type: string
                      sort_order:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-posts:
    get:
      summary: List blog posts
      operationId: get_blog_posts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_blogs
      x-required-scopes: [read_blogs]
      parameters:
        - name: slug
          in: query
          required: false
          schema: { type: string }
          description: >-
            Resolves a storefront URL segment to a post. Unique per store, so it returns at
            most one row.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, published, archived] }
          description: >-
            Posts in one state. REQUIRES write_blogs, because it selects among a store's
            unpublished work; a key holding read_blogs alone receives a 403 rather than a
            silently published-only page. An unrecognised value is a 400 naming the accepted
            set.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Posts touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading every post body on every sync. Supplying it CHANGES THE
            ORDER of this endpoint: the default walk descends created_at, and this one climbs
            updated_at. It is NOT ordered by published_at, which is the field you would reach
            for first and the one ordering that cannot work: unpublishing clears it and
            republishing re-stamps it, so a keyset walk over published_at moves a row
            backwards across your cursor and drops it. updated_at is rewritten by those same
            actions but only ever forwards, so a post edited mid-walk moves forward past your
            position and is returned again. The walk is AT LEAST ONCE: apply the state of each
            row you receive and a repeat costs nothing, while a miss means serving stale body
            content you have no way to discover is stale. THE VISIBILITY GATE STILL APPLIES:
            a read_blogs key walks published posts only, exactly as on the default list, and
            drafts never appear on either. The bound is INCLUSIVE, so resuming from the newest
            updated_at you hold re-delivers the rows sharing that instant. A cursor belongs to
            ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_bio:
                          nullable: true
                        author_name:
                          nullable: true
                        body:
                          nullable: true
                        category:
                          nullable: true
                        cover_image_url:
                          nullable: true
                        created_at:
                          type: string
                        excerpt:
                          nullable: true
                        id:
                          type: string
                        published_at:
                          type: string
                        reading_time_minutes:
                          type: [integer, "null"]
                        seo_meta_description:
                          nullable: true
                        seo_meta_title:
                          nullable: true
                        seo_og_image_url:
                          nullable: true
                        slug:
                          type: string
                        status:
                          type: string
                        tags:
                          type: array
                          items:
                            {}
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a blog post
      operationId: post_blog_posts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, title]
              additionalProperties: false
              description: >-
                EVERY post created here lands as a DRAFT, and status is not settable:
                publishing puts an article on the storefront and into the feed of
                every key holding read_blogs immediately, with no review step, so it
                is a second deliberate act at POST /blog-posts/{postId}/publish. A
                body carrying "status" is refused rather than ignored, because a 201
                that quietly dropped it would leave the client believing an article is
                live that no reader can reach.
                The slug must be unique in the store; a taken slug is 409, which is
                what makes a create replayed outside the idempotency window safe.
                scheduled_publish_at is refused: the column exists and NOTHING reads
                it, so a value there would be a countdown that never fires.
              properties:
                slug:
                  type: string
                  description: The storefront path segment. Unique per store.
                title:
                  type: string
                excerpt:
                  type: [string, "null"]
                body:
                  type: [object, "null"]
                  description: >-
                    A ProseMirror JSON document, NOT html and not a string. It is
                    stored byte for byte after a size check: there is no node
                    allowlist, no mark allowlist and no URL scheme check inside it, so
                    anything rendering it must apply its own.
                cover_image_url:
                  type: [string, "null"]
                  description: An absolute http(s) url under 2048 characters.
                author_name:
                  type: [string, "null"]
                author_bio:
                  type: [string, "null"]
                tags:
                  type: array
                  items:
                    type: string
                reading_time_minutes:
                  type: [integer, "null"]
                  description: Merchant-supplied. Nothing computes it.
                seo_meta_title:
                  type: [string, "null"]
                seo_meta_description:
                  type: [string, "null"]
                seo_og_image_url:
                  type: [string, "null"]
                category_id:
                  type: [string, "null"]
                  description: >-
                    A blog category in this store. An id from another store is a 400
                    rather than a filed post.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: [string, "null"]
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-posts/{postId}:
    get:
      summary: Retrieve a blog post
      operationId: get_blog_posts_by_postId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_blogs
      x-required-scopes: [read_blogs]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: string
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a blog post
      operationId: patch_blog_posts_by_postId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                An omitted field is left unchanged; a nullable field sent as NULL is
                CLEARED, which is the only way to remove a stale SEO title that is
                still being served. slug and title are NOT NULL columns and
                reading_time_minutes is set-only, so null on any of the three is a 400
                rather than a silent skip; send [] to remove every tag.
                A patch naming nothing is refused, so a client with a misspelled field
                does not read a green light for a no-op.
                PATCH rather than PUT: status, published_at, scheduled_publish_at and
                the post's revisions are all state this body cannot express, so a
                whole-resource replacement reading would be a lie. Every value is an
                absolute target, so replaying converges.
                A patch to a PUBLISHED post is live immediately; there is no draft
                copy of a live post.
                CHANGING slug CHANGES THE LIVE URL and nothing writes a redirect for
                you: every inbound link, sent email and indexed result pointing at the
                old path 404s from the moment this returns 200. Renaming an unpublished
                post is free because a draft is not served. After publish, pair the
                rename with POST /api/v1/redirects in the same run.
                expected_updated_at IS OPTIONAL AND ENFORCED WHEN SENT. Send back the
                updated_at you read and a write that would revert somebody else's edit is
                refused 409 post_modified instead of landing silently. Omit it and the
                write behaves exactly as it always has, last-write-wins: it is optional
                because this route shipped without it, and requiring it now would break
                every deployed client. It is compared at the precision you express, so a
                millisecond timestamp satisfies a microsecond one.
              properties:
                expected_updated_at:
                  type: string
                  format: date-time
                slug:
                  type: string
                title:
                  type: string
                excerpt:
                  type: [string, "null"]
                body:
                  type: [object, "null"]
                cover_image_url:
                  type: [string, "null"]
                author_name:
                  type: [string, "null"]
                author_bio:
                  type: [string, "null"]
                tags:
                  type: array
                  items:
                    type: string
                reading_time_minutes:
                  type: integer
                seo_meta_title:
                  type: [string, "null"]
                seo_meta_description:
                  type: [string, "null"]
                seo_og_image_url:
                  type: [string, "null"]
                category_id:
                  type: [string, "null"]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: [string, "null"]
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-posts/{postId}/archive:
    post:
      summary: Archive a blog post
      operationId: post_blog_posts_by_postId_archive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Retires a post: it
                drops out of the storefront and out of the /blog-posts read family, and
                nothing is destroyed.
                THE DIFFERENCE FROM UNPUBLISH is published_at. Archive KEEPS it, so the
                date the post first went live survives as history; unpublish clears it
                and republishing stamps a new one. Use archive for a post that had a
                life and is retired, unpublish for one going back to draft.
                IT IS NOT A DELETE. This family publishes none, because the underlying
                delete is a hard DELETE that takes the body, the slug and every revision
                and 404s every inbound link. Archive is reversible by
                POST /blog-posts/{postId}/publish.
                It exists because status already publishes "archived" on every read, so
                until this route landed a client could observe a state no call could
                produce. Absolute, so a replay converges: archiving an archived post
                leaves it archived with published_at untouched.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: [string, "null"]
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-posts/{postId}/publish:
    post:
      summary: Publish a blog post
      operationId: post_blog_posts_by_postId_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Puts the article
                on the storefront and into the /blog-posts read family immediately:
                there is no review step, no scheduling and no event telling anyone.
                published_at is stamped on FIRST publish and then frozen, so a
                republish does not restate the article as new.
                Reversed by POST /blog-posts/{postId}/unpublish. Publishing an
                already-published post is not an error.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: [string, "null"]
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/blog-posts/{postId}/unpublish:
    post:
      summary: Unpublish a blog post
      operationId: post_blog_posts_by_postId_unpublish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_blogs
      x-required-scopes: [write_blogs]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Returns the post
                to draft, removing it from the storefront and from the /blog-posts
                read family while keeping the body, the slug and every revision.
                It CLEARS published_at, unlike the page family's unpublish which keeps
                it, so republishing stamps a new one.
                This is what a migration wants instead of a delete, and it is why this
                family publishes NO delete: unpublish is reversible and a hard delete
                that 404s every inbound link is not.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_bio:
                        type: [string, "null"]
                      author_name:
                        type: [string, "null"]
                      body:
                        type: [object, "null"]
                        properties:
                          content:
                            type: array
                            items:
                              type: object
                              properties:
                                content:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      text:
                                        type: string
                                      type:
                                        type: string
                                type:
                                  type: string
                          type:
                            type: string
                      category:
                        type: [object, "null"]
                        properties:
                          id:
                            type: string
                          name:
                            type: string
                          slug:
                            type: string
                      cover_image_url:
                        type: [string, "null"]
                      created_at:
                        type: string
                      excerpt:
                        type: [string, "null"]
                      id:
                        type: string
                      published_at:
                        type: [string, "null"]
                      reading_time_minutes:
                        type: [integer, "null"]
                      seo_meta_description:
                        type: [string, "null"]
                      seo_meta_title:
                        type: [string, "null"]
                      seo_og_image_url:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_blogs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/bundles:
    get:
      summary: List bundles
      operationId: get_bundles
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_bundles
      x-required-scopes: [read_bundles]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Bundles in one state. An unrecognised value is a 400 naming the accepted set
            rather than an empty page.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Bundles touched at or after this RFC3339 instant. A bundle's STATUS and its
            component set decide what a shopper can buy and at what price, and both are edited
            long after creation, so a cached copy keeps offering one the merchant has disabled
            or re-costed. Supplying it CHANGES THE ORDER of this endpoint: the default walk
            descends created_at, and this one climbs updated_at, which is the only ordering in
            which a keyset walk over a mutable column cannot skip a row. A bundle edited
            mid-walk moves forward past your position and is returned again, so the walk is AT
            LEAST ONCE. The bound is INCLUSIVE, so resuming from the newest updated_at you
            hold re-delivers the rows sharing that instant. A cursor belongs to ONE ordering
            and is refused by the other. THE ONE CAVEAT: this tracks the BUNDLE row, and the
            component join carries no timestamp of its own, so adding or removing a component
            does not move the bundle here unless the same write touches the parent. Reconcile
            components from the bundle's detail read.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              quantity:
                                type: integer
                              variant_id:
                                type: string
                        price_minor:
                          type: integer
                        status:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a bundle
      operationId: post_bundles
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_bundles
      x-required-scopes: [write_bundles]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, price_minor, currency_code]
              additionalProperties: false
              description: >-
                Creates a bundle BARE. The items array in the response is present and EMPTY,
                which is a true state rather than a placeholder: a bundle with no components
                is not purchasable, and the storefront reports that as an ordinary out of
                stock rather than as an error. Add components with
                POST /api/v1/bundles/{bundleId}/items afterwards.
                IT DOES NOT CONVERGE PAST THE IDEMPOTENCY WINDOW, and this family has to say
                so plainly because the retail-location and company families in this same
                document do converge. product_bundle has NO unique column besides its own id:
                title is free-form and the patch rewrites it, so nothing stops two identical
                bundles existing side by side. Replaying the Idempotency-Key returns the
                recorded 201 without re-running anything, but a retry with a FRESH key mints a
                SECOND bundle rather than answering 409. Reuse your own key on a retry; there
                is no server-side dedupe to fall back on.
                price_minor IS REQUIRED even though the column would take a zero happily, and
                that is the one that matters. The CHECK permits 0, so an omitted price would
                mint a bundle that gives its components away for nothing, pass every check on
                the way in and be refused by nothing downstream: the storefront would render
                it, the cart would allocate zero across the components, and the first evidence
                would be a paid order worth nothing. A genuinely free bundle costs one
                explicit 0.
                NOTHING IS EMITTED. This family registers no webhook topics, so a client
                mirroring bundles must poll. The bundle is resolved when a cart is priced
                rather than snapshotted anywhere, so an active bundle is sellable as soon as
                it has components.
                REFUSED BY NAME rather than ignored: items, because components are written one
                at a time and a whole-array replace here would remove every component the
                array left out and silently reprice the survivors; component_sum, because it
                is computed from the component variants' own prices rather than stored and is
                not published on the read either; price, because the field is price_minor;
                available_quantity, in_stock and unlimited, because stock is inventory's
                resource and all three are derived; and the provenance keys (id, store_id,
                created_at, updated_at), because the server mints and owns them and your api
                key already names the store.
                NO KEY MAY BE SENT AS null. Nothing this API writes on a bundle is nullable,
                so a null answers 400 naming the field; omit the key instead. The whole body
                is capped at 1 MiB.
              properties:
                title:
                  type: string
                  description: >-
                    Required, and TRUNCATED AT 200 BYTES rather than refused, matching the
                    dashboard. A published API that silently shortens input has to admit it,
                    so read the response back if your titles run long. Blank or whitespace is
                    a 400.
                price_minor:
                  type: integer
                  minimum: 0
                  maximum: 1000000000000
                  description: >-
                    Required. The bundle's AUTHORITATIVE set price in INTEGER MINOR UNITS of
                    currency_code (1299 is 12.99 in a two-decimal currency), never derived
                    from what the components are worth separately. THIS is the number a
                    shopper is charged, and the cart allocates it across whatever components
                    the bundle has at the time, so it is also what decides what each component
                    contributes per unit.
                currency_code:
                  type: string
                  description: >-
                    Required, and it is what makes price_minor readable. Accepted in any case
                    and STORED LOWERCASE, so a client sending "USD" reads back "usd"; the read
                    contract is already frozen on the stored form. Validated against the
                    ISO 4217 catalogue, so an unknown code is a 400 rather than an unreadable
                    amount.
                status:
                  type: string
                  enum: [active, disabled]
                  description: >-
                    Optional, defaulting to active, which is the column's own default. This is
                    the reversible withdrawal this family offers instead of a delete: disabled
                    takes the bundle out of sale and keeps every component, and one more patch
                    puts it back.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          {}
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/bundles/{bundleId}:
    get:
      summary: Retrieve a bundle
      operationId: get_bundles_by_bundleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_bundles
      x-required-scopes: [read_bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            quantity:
                              type: integer
                            variant_id:
                              type: string
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a bundle's title, price, currency or status
      operationId: patch_bundles_by_bundleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_bundles
      x-required-scopes: [write_bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                PATCH rather than PUT, and the method is the contract: an omitted key keeps
                its stored value, so a client that computed a partial body cannot blank the
                merchant's title or reset the price to zero and read a 200 as agreement. The
                same four keys the create accepts are writable here, because a field that can
                be set once and never corrected is worse than one never published: the only
                remaining fix would be delete-and-recreate, and this family publishes no
                delete.
                A PATCH THAT ONLY MOVES price_minor CHANGES WHAT EVERY COMPONENT IS WORTH PER
                UNIT AT CHECKOUT, because the bundle carries one authoritative set price and
                the cart spreads it across whatever components exist. That is the intended use
                of this route rather than a hazard, and it is worth knowing when reading an
                audit trail: nothing about the components changed.
                AN EMPTY PATCH IS A 400, not a no-op. This resource publishes updated_at as
                the incremental-sync key, so a bumped timestamp on a request that changed
                nothing tells every polling client to re-pull for nothing.
                IT DOES NOT TOUCH COMPONENTS, and items is refused BY NAME because the read
                EMBEDS the array and the first instinct is to send it back with one element
                changed. Accepting that would be a whole-array replace: any component left out
                would be removed, and because the set price is allocated across whatever
                remains, the survivors would each silently become worth MORE per unit. A
                client that dropped one element by accident would reprice the rest and get a
                200 for it.
                IT CONVERGES. Every accepted value is absolute rather than a delta, so
                replaying the same body with a fresh key past the idempotency window lands the
                same bundle in the same state. No money and no stock move, and nothing is
                emitted: this family registers no webhook topics.
                THE RESPONSE IS THE WHOLE BUNDLE, components included, read in the same
                transaction as the merge, so it is byte-identical to
                GET /api/v1/bundles/{bundleId} rather than a fragment.
                component_sum, price, available_quantity, in_stock, unlimited and the
                provenance keys (id, store_id, created_at, updated_at) are refused for the
                create's reasons. No key may be null: nothing here is nullable, so null cannot
                mean "clear it" and is a 400 naming the field. A bundle id that resolves to
                nothing in this store is a generic 404.
              properties:
                title:
                  type: string
                  description: >-
                    Truncated at 200 bytes, as on the create. Present but blank or whitespace
                    is a 400 naming the key rather than a stored empty title: omit the key to
                    leave it unchanged.
                price_minor:
                  type: integer
                  minimum: 0
                  maximum: 1000000000000
                  description: >-
                    The set price in INTEGER MINOR UNITS of the bundle's currency. Absolute,
                    never a delta, and re-divided across the components at the next cart
                    recompute.
                currency_code:
                  type: string
                  description: >-
                    Stored lowercase and validated against the ISO 4217 catalogue. Present but
                    blank is a 400. Re-denominating a bundle does NOT convert price_minor:
                    the same integer is then read in the new currency's minor units, so send
                    both keys together when you mean to move markets.
                status:
                  type: string
                  enum: [active, disabled]
                  description: >-
                    disabled withdraws the bundle from sale and keeps every component, which
                    is this family's reversible stand-in for a delete. active puts it back.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            quantity:
                              type: integer
                            variant_id:
                              type: string
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/bundles/{bundleId}/items:
    post:
      summary: Add a component variant to a bundle
      operationId: post_bundles_by_bundleId_items
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_bundles
      x-required-scopes: [write_bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, quantity]
              additionalProperties: false
              description: >-
                IT ANSWERS WITH THE WHOLE BUNDLE, 200, not with the component it created, and
                that is the shape the item routes rest on. A component is not addressable for
                reads: it is embedded in the bundle rather than served from a sub-route, and
                the read publishes neither its own id nor its bundle_id, so a 201 carrying
                {variant_id, quantity} would hand back a fragment with no self URL and nothing
                to re-fetch it by. 200 rather than 201 for the same reason: the resource in
                the body is the bundle, which already existed.
                ADDING A COMPONENT CHANGES WHAT EVERY OTHER COMPONENT IS WORTH PER UNIT AT
                CHECKOUT. The bundle carries one authoritative fixed set price and the cart
                allocates that one price across whatever components exist, so a new component
                DILUTES the others rather than adding to the bundle's price. The shopper pays
                the same total; the split moves. That is also why the add is on the plain write
                grant while removal is not published at all: dilution is undone by the reverse
                operation, and a removal would concentrate value into fewer components and
                cannot be undone from this API, because re-adding does not restore a
                component's original position in the allocation order.
                A DUPLICATE COMPONENT IS 409 item_exists, from the unique index on (bundle,
                variant), which is stronger convergence than the bundle create's: a retry with
                a FRESH key past the idempotency window collides instead of silently doubling
                a quantity. Change an existing component with
                PATCH /api/v1/bundles/{bundleId}/items/{variantId}.
                THERE IS NO REMOVE ROUTE, and it is a refusal rather than an omission.
                Dropping a component succeeds silently and reprices every survivor upward, and
                dropping the last one makes the bundle unpurchasable, which the storefront
                reports as an ordinary out of stock. apiscope publishes no write_bundles:delete
                to gate such a route with, so it stays a dashboard action.
                NO STOCK MOVES and no money moves. Components are a definition, not a
                reservation: what a bundle can sell is read from the component variants'
                inventory at cart time and is published per variant on
                /api/v1/inventory-levels. The parent's updated_at advances in the same
                transaction, which is what lets a client record the new sync timestamp without
                a second call. Nothing is emitted.
                bundle_id is refused BY NAME because the URL already names the bundle and a
                body disagreeing with the path would need a precedence rule nobody should have
                to learn; moving a component between bundles is a remove plus an add. price
                and price_minor are refused because a component has no price of its own, and
                the provenance keys (id, store_id, created_at, updated_at) because the server
                owns them and nothing addresses a component by its own id. No key may be null.
              properties:
                variant_id:
                  type: string
                  description: >-
                    Required. A product variant in THIS store, the same id the products
                    resource publishes inside its variants array. It is checked under
                    Row-Level Security rather than by a foreign key alone, so a variant
                    belonging to another merchant reads as absent and answers 422
                    variant_not_found rather than resolving. A bundle id that names nothing is
                    a 404 instead, so the two halves of the URL are distinguishable.
                quantity:
                  type: integer
                  minimum: 1
                  maximum: 1000000
                  description: >-
                    Required, with no default. How many of this variant ONE bundle contains,
                    never a stock figure and never a number of bundles. An omitted quantity
                    would read as zero, which the column refuses, so it is asked for rather
                    than guessed. A value outside the range is a 400.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            quantity:
                              type: integer
                            variant_id:
                              type: string
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/bundles/{bundleId}/items/{variantId}:
    delete:
      summary: Remove a component from a bundle
      operationId: delete_bundles_by_bundleId_items_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_bundles:delete
      x-required-scopes: [write_bundles, write_bundles:delete]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                THIS CHANGES MONEY. A bundle carries one authoritative set price and that
                price is allocated across whatever components exist at the time, so
                removing a component does NOT make the bundle cheaper: every surviving
                component becomes worth more per unit at the same total to the shopper.
                That is why it needs write_bundles:delete in addition to write_bundles.
                REMOVING THE LAST COMPONENT IS REFUSED with 409 last_bundle_item. The
                bundle would keep its price with nothing to allocate it across, stop being
                purchasable, and the storefront would report that as an ordinary
                out-of-stock, so you would go looking for a supply problem that is not
                there. PATCH the bundle's status to "disabled" to withdraw it from sale,
                which is reversible.
                It answers with the WHOLE bundle rather than 204, because the removal
                re-divides the set price and you need to see where the rest landed.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            quantity:
                              type: integer
                            variant_id:
                              type: string
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_bundles and then on write_bundles:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_bundles, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Change how many of a variant a bundle contains
      operationId: patch_bundles_by_bundleId_items_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_bundles
      x-required-scopes: [write_bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quantity]
              additionalProperties: false
              description: >-
                THE COMPONENT IS ADDRESSED BY ITS VARIANT ID, never by the component row's own
                id, because (bundle, variant) is the real natural key and nothing on this
                platform addresses a component any other way. It also matches what the read
                publishes, so this URL is built from the items array a client already holds.
                QUANTITY IS THE ONLY WRITABLE FIELD and it is REQUIRED, so this route has no
                empty patch to refuse. variant_id is refused BY NAME: swapping which product
                is in a bundle would silently repoint the component and, because the set price
                is allocated across whatever components exist, change what every other one is
                worth per unit. A swap is a remove plus an add, and the remove is not
                published here at all.
                CHANGING A QUANTITY RE-DIVIDES THE SAME SET PRICE. The bundle's price_minor
                does not move, so raising one component's quantity lowers what every other
                component is worth per unit at checkout and the shopper still pays the same
                total. It takes effect at the next cart recompute, because a bundle is
                resolved when a cart is priced rather than snapshotted.
                IT IS NOT AN UPSERT. A variant that is not already a component answers 404
                item_not_found, never an implicit insert, because a client that meant to add
                one asked the wrong route and a typo in the URL would otherwise quietly
                enlarge the bundle. The two 404s this route can answer carry different codes
                (not_found for the bundle, item_not_found for the component) so a client can
                tell which half of the URL was wrong.
                IT CONVERGES: quantity is absolute, so a replay with a fresh key sets the same
                component to the same number. No stock moves and no money moves, and nothing
                is emitted. The response is the WHOLE bundle, for the add route's reasons.
                bundle_id, price, price_minor and the provenance keys (id, store_id,
                created_at, updated_at) are refused BY NAME. No key may be null.
              properties:
                quantity:
                  type: integer
                  minimum: 1
                  maximum: 1000000
                  description: >-
                    Required. How many of the variant named in the path ONE bundle contains.
                    There is no way to say zero: the column refuses it, and removing a
                    component is not published on this API.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            quantity:
                              type: integer
                            variant_id:
                              type: string
                      price_minor:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_bundles scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/carriers:
    get:
      summary: List carriers
      operationId: get_carriers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carriers
      x-required-scopes: [read_carriers]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        carrier_code:
                          type: string
                        created_at:
                          type: string
                        has_credentials:
                          type: boolean
                        is_enabled:
                          type: boolean
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carriers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/carriers/{carrierCode}:
    get:
      summary: Retrieve a carrier
      operationId: get_carriers_by_carrierCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carriers
      x-required-scopes: [read_carriers]
      parameters:
        - name: carrierCode
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      carrier_code:
                        type: string
                      created_at:
                        type: string
                      has_credentials:
                        type: boolean
                      is_enabled:
                        type: boolean
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carriers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/cart-recovery-optouts:
    get:
      summary: List cart recovery optouts
      description: >-
        The store's SUPPRESSION list: every address that asked this merchant to stop
        mailing them. It is published so you can honour the same suppressions in your
        own sending. It is not a mailing list, and treating it as a source of
        contactable people inverts its entire meaning.
        THERE IS NO GET BY ADDRESS because ?email= answers the same question better.
        "Is this address suppressed" returns an empty page when it is not, which is
        the correct answer; a 404 would be an error status for a perfectly normal
        outcome and every client would have to treat it as success.
        Addresses are stored lowercased and trimmed. Normalise the same way before
        comparing, or a mixed-case address reads as absent and mail goes to someone
        who asked a merchant to stop.
      operationId: get_cart_recovery_optouts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_cart_recovery
      x-required-scopes: [read_cart_recovery]
      parameters:
        - name: email
          in: query
          required: false
          schema: { type: string }
          description: >-
            Exact match, not a prefix search, so this list cannot be enumerated by
            guessing. It is how you ask whether one address is suppressed before a
            send: an address that is not returns an EMPTY PAGE rather than a 404,
            because "nobody suppressed" is a normal answer and not an error. The
            value is lowercased and trimmed before it is matched, so case does not
            matter here. A malformed address is also an empty page rather than a
            400: on a filter that is a question with a correct answer.
        - name: reason
          in: query
          required: false
          schema: { type: string, enum: [unsubscribe, bounce, complaint, manual] }
          description: >-
            Filter by why the address was suppressed. An unrecognised value is a 400
            rather than an empty page: "no address was suppressed for that reason"
            and "there is no such reason" are different facts, and a client told the
            former concludes the complaints it forwarded never arrived.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        reason:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Suppress an address from cart-recovery mail
      description: >-
        Suppresses an address, so this store's recovery mail never reaches it again.
        This is how an ESP forwards its own unsubscribes, bounces and complaints into
        the store's list.
        RE-ADDING AN ALREADY-SUPPRESSED ADDRESS IS NOT AN ERROR and does not move it.
        You get back the ORIGINAL reason and timestamp, so a complaint recorded last
        month is not restamped as today's manual entry, and a webhook redelivery
        cannot turn into a retry storm.
        THERE IS NO WAY TO REMOVE A SUPPRESSION THROUGH THIS API, deliberately.
        Removing one re-enrols a person who explicitly asked to stop, so no scope
        buys it; the merchant can do it from their own dashboard.
      operationId: post_cart_recovery_optouts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_cart_recovery
      x-required-scopes: [write_cart_recovery]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, reason]
              additionalProperties: false
              description: >-
                Records a suppression. 201 carries the suppression NOW IN FORCE,
                which on a repeat is the ORIGINAL row rather than the one you just
                described, so compare what comes back if you need to know which.
                THERE IS NO UNDO ON THIS API. Nothing here removes a suppression,
                because removing one re-enrols a person who explicitly asked to
                stop.
              properties:
                email:
                  type: string
                  description: >-
                    The address to suppress. Lowercased and trimmed before it is
                    stored, so it will come back normalised whatever you send.
                reason:
                  type: string
                  enum: [unsubscribe, bounce, complaint, manual]
                  description: >-
                    Why the address is suppressed, and REQUIRED rather than
                    defaulted. An unrecognised value is a 400 here, while the
                    merchant's dashboard coerces one to "manual": that default is
                    reasonable for a person typing in a form and a trap for a
                    machine, because a misspelled or omitted "complaint" would file
                    every complaint as a manual removal, the store's compliance
                    export would report none, and nothing anywhere would say so.
                    unsubscribe and complaint are the shopper's own decision and must
                    be honoured everywhere; bounce is a delivery fact that may be
                    revisited, and manual is the merchant's own entry.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      reason:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/cart-recovery-optouts/{email}:
    delete:
      summary: Lift a cart-recovery suppression
      operationId: delete_cart_recovery_optouts_by_email
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_cart_recovery
      x-required-scopes: [write_cart_recovery]
      parameters:
        - name: email
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                ONLY A bounce OR A manual SUPPRESSION CAN BE LIFTED. unsubscribe and
                complaint are the shopper's own decision, are honoured everywhere, and are
                refused 403 optout_not_liftable naming the reason that was found. A bounce
                is a delivery fact that can go stale (a full mailbox, a misconfigured
                domain), and manual is your own entry, so both are yours to revisit.
                THE ADDRESS IS THE PATH SEGMENT: this resource publishes no id and its
                natural key is the email, stored lowercased and trimmed. Percent-encode it
                like any path segment.
                An address with no suppression is a 404 rather than a 204, because a fresh
                Idempotency-Key against one means you believe a suppression is there; a
                REPLAYED key still returns the stored 204.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/cart-recovery-settings:
    get:
      summary: Retrieve cart recovery settings
      description: >-
        The abandoned-cart programme: whether it is on, how long after a cart goes
        idle each of the three recovery emails is sent, and the sender name and
        discount code that mail carries.
        A store that has never saved settings reads back the DISABLED DEFAULT rather
        than a 404. The programme exists for every store and is off until switched
        on, and a 404 would say the feature is unavailable on this platform, which
        is a different and wrong conclusion.
      operationId: get_cart_recovery_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_cart_recovery
      x-required-scopes: [read_cart_recovery]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      discount_code:
                        type: [string, "null"]
                      enabled:
                        type: boolean
                      sender_name:
                        type: [string, "null"]
                      stage1_minutes:
                        type: integer
                      stage2_minutes:
                        type: integer
                      stage3_minutes:
                        type: integer
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace the cart-recovery settings
      description: >-
        Replaces the whole programme document. Read it, edit what you received, send
        it back.
        THE CADENCE IS IN MINUTES and must strictly increase. The background scan
        spaces each stage from the PREVIOUS SEND rather than from the idle moment, so
        delays that are out of order produce a negative gap and the stage fires
        immediately after its predecessor, mailing one shopper three times in a
        minute. Out-of-order or non-positive delays are refused with 400.
      operationId: put_cart_recovery_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_cart_recovery
      x-required-scopes: [write_cart_recovery]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled, stage1_minutes, stage2_minutes, stage3_minutes, sender_name, discount_code, expected_updated_at]
              additionalProperties: false
              description: >-
                EVERY KEY IS REQUIRED, including the three that may be null. The save
                underneath is an upsert of the whole row, so an omitted key is a
                CLEARED field and not an unchanged one: sending {"enabled": true}
                alone would blank the sender name and the discount code and zero the
                cadence. The cadence at least fails loudly; the two strings would
                blank in silence and the merchant would find out from the next batch
                of recovery mail, which goes out on a background scan hours later
                with no request to correlate it to. A missing key is a 400 naming it.
                Read the settings, edit the document you received, and send it back.
              properties:
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. Required, because these settings are
                    a singleton written by this route AND by the merchant's dashboard:
                    a write naming no version silently reverts whatever moved under it,
                    answers 200 to both parties, and surfaces only as recovery mail on
                    the old cadence hours later. A mismatch is 409 settings_modified.
                    null asserts that this store has never saved settings, which is the
                    only way a first write can name a version that does not exist, and
                    it is refused once a row does exist.
                enabled:
                  type: boolean
                  description: >-
                    The single switch. False and the background scan skips this store
                    entirely, however good the cadence is.
                stage1_minutes:
                  type: integer
                  description: >-
                    MINUTES after the cart goes idle for the first recovery email.
                    Read as hours it would mail a shopper sixty times sooner than
                    intended, which is why stage1_hours is refused by name.
                stage2_minutes:
                  type: integer
                  description: >-
                    MINUTES after the cart goes idle for the second email. Must be
                    greater than stage1_minutes.
                stage3_minutes:
                  type: integer
                  description: >-
                    MINUTES after the cart goes idle for the third email. Must be
                    greater than stage2_minutes. All three must be positive and
                    STRICTLY INCREASING: the scan spaces each stage from the previous
                    SEND, so a gap computed from out-of-order delays is negative and
                    the stage fires immediately after its predecessor.
                sender_name:
                  type: [string, "null"]
                  description: >-
                    The display name on the recovery email, null to fall back to the
                    store's notification defaults. It is NOT the from address: that
                    lives on the store's notification provider settings, which this
                    family neither reads nor writes, and sender_email is refused by
                    name for that reason.
                discount_code:
                  type: [string, "null"]
                  description: >-
                    The promotion code the recovery email carries, null for none.
                    One code per store, mailed in plaintext to every abandoning
                    shopper, so it is published on the read as well.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      discount_code:
                        type: [string, "null"]
                      enabled:
                        type: boolean
                      sender_name:
                        type: [string, "null"]
                      stage1_minutes:
                        type: integer
                      stage2_minutes:
                        type: integer
                      stage3_minutes:
                        type: integer
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/cart-recovery-stats:
    get:
      summary: Retrieve cart recovery stats
      description: >-
        How many recovery emails were dispatched and how many of those carts came
        back, over the store's whole history.
        RECOVERED REVENUE IS DELIBERATELY NOT PUBLISHED. The stored amount carries no
        currency beside it, so in a multi-region store summing it adds one currency's
        minor units to another's and yields a single number no caller could tell was
        nonsense. Recovery RATE is derivable from these two counts, needs no currency,
        and is the figure a cadence is actually tuned against.
      operationId: get_cart_recovery_stats
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_cart_recovery
      x-required-scopes: [read_cart_recovery]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recovered_count:
                        type: integer
                      sent_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_cart_recovery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/carts:
    get:
      summary: List carts
      operationId: get_carts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carts
      x-required-scopes: [read_carts]
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this customer_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: string
                        email:
                          type: string
                        id:
                          type: string
                        line_items:
                          type: array
                          items:
                            type: object
                            properties:
                              effective_unit_price_minor:
                                type: integer
                              form_price_delta_minor:
                                type: integer
                              id:
                                type: string
                              product_title:
                                type: string
                              quantity:
                                type: integer
                              unit_price_minor:
                                type: integer
                              variant_id:
                                type: string
                              variant_title:
                                type: string
                        status:
                          type: string
                        subtotal_minor:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/carts/{cartId}:
    get:
      summary: Retrieve a cart
      operationId: get_carts_by_cartId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carts
      x-required-scopes: [read_carts]
      parameters:
        - name: cartId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: string
                      email:
                        type: string
                      id:
                        type: string
                      line_items:
                        type: array
                        items:
                          type: object
                          properties:
                            effective_unit_price_minor:
                              type: integer
                            form_price_delta_minor:
                              type: integer
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                            variant_title:
                              type: string
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories:
    get:
      summary: List categories
      operationId: get_categories
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_categories
      x-required-scopes: [read_categories]
      parameters:
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
          description: >-
            Narrows to live or hidden categories. Read STRICTLY: exactly true or false, or
            omitted. An unrecognised value is a 400 rather than "no filter", because an
            integrator with a typo would otherwise receive every category, believe they had
            asked for only the live ones, and never learn otherwise.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Categories touched at or after this RFC3339 instant, for reconciling
            incrementally instead of re-reading the whole taxonomy. Supplying it CHANGES THE
            ORDER of this endpoint: the default walk descends created_at, and this one
            climbs updated_at, because that is the only ordering in which a keyset walk over
            a column the merchant keeps rewriting cannot skip a row. A category renamed,
            re-ranked or re-parented mid-walk moves forward past your position and is
            returned again, so the walk is AT LEAST ONCE: apply the state of each row you
            receive and a repeat costs nothing, while a miss would be storefront navigation
            silently diverging from what the merchant set. The bound is INCLUSIVE, so
            resuming from the newest updated_at you hold re-delivers the rows sharing that
            instant rather than dropping the ones your last page did not reach. A cursor
            belongs to ONE ordering and is refused by the other. ARCHIVED CATEGORIES LEAVE
            THIS WALK SILENTLY, as they leave the default list: it tells you what changed,
            not what went away.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        description:
                          type: string
                        handle:
                          type: string
                        id:
                          type: string
                        is_active:
                          type: boolean
                        name:
                          type: string
                        parent_id:
                          nullable: true
                        rank:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a category
      operationId: post_categories
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories
      x-required-scopes: [write_categories]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, handle]
              additionalProperties: false
              properties:
                name:
                  type: string
                handle:
                  type: string
                parent_id:
                  type: [string, "null"]
                  description: >-
                    Omit or send null for a root category. A category created here is
                    INACTIVE, so it cannot appear under a hidden parent; activate it
                    once its parent is visible.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}:
    delete:
      summary: Delete a category
      operationId: delete_categories_by_categoryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories:delete
      x-required-scopes: [write_categories, write_categories:delete]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_categories and then on write_categories:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_categories, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a category
      operationId: get_categories_by_categoryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_categories
      x-required-scopes: [read_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a category
      operationId: patch_categories_by_categoryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories
      x-required-scopes: [write_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted field is left unchanged. Reparenting is NOT available here
                and there is no move route: a reparent can place an active category
                under a hidden parent, which create and activate both refuse.
              properties:
                name:
                  type: string
                handle:
                  type: string
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}/activate:
    post:
      summary: Make a category visible to shoppers
      operationId: post_categories_by_categoryId_activate
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories
      x-required-scopes: [write_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}/deactivate:
    post:
      summary: Hide a category from shoppers
      operationId: post_categories_by_categoryId_deactivate
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories
      x-required-scopes: [write_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}/move:
    post:
      summary: Move a category and set its position
      operationId: post_categories_by_categoryId_move
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories
      x-required-scopes: [write_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [parent_id, rank]
              additionalProperties: false
              description: >-
                Both keys are required and neither is defaulted: omitting parent_id
                would silently re-root a nested branch. Send null for parent_id to move
                a category to the top level.
              properties:
                parent_id:
                  type: [string, "null"]
                  description: >-
                    The destination parent. An ACTIVE category cannot be moved under a
                    HIDDEN one: the storefront will not render that branch, so the move
                    is refused rather than silently producing an unreachable node.
                rank:
                  type: integer
                  description: >-
                    Position among the destination's children, clamped to the ends. The
                    siblings are renumbered around it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}/products:
    get:
      summary: List a category's products
      operationId: get_categories_by_categoryId_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_categories
      x-required-scopes: [read_categories]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        product_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_categories scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/categories/{categoryId}/restore:
    post:
      summary: Restore a deleted category
      operationId: post_categories_by_categoryId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_categories:delete
      x-required-scopes: [write_categories, write_categories:delete]
      parameters:
        - name: categoryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Brings back a
                category removed by DELETE together with every product assigned to it
                and its place in the tree. Answers 409 if another category has taken
                its handle in the meantime.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      parent_id:
                        nullable: true
                      rank:
                        type: number
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_categories and then on write_categories:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_categories, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections:
    get:
      summary: List collections
      description: >-
        The store's collections, newest first.
        ARCHIVED COLLECTIONS ARE INCLUDED BY DEFAULT. That is long-standing behaviour and
        it is not changing: withdrawing them from the unfiltered page would silently alter
        what every deployed integration receives, and a response cannot be taken back on
        this API. Send ?status=active for live collections only, or ?status=archived for
        the archived ones. An unrecognised value is a 400 rather than an empty page, which
        would otherwise read as "this merchant has no collections".
        ?handle= resolves a storefront URL segment to a collection. It is unique per store,
        so it returns at most one row, and it is the only identifier an integrator holding
        a live URL has.
      operationId: get_collections
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_collections
      x-required-scopes: [read_collections]
      parameters:
        - name: handle
          in: query
          required: false
          schema: { type: string }
          description: >-
            Resolves a storefront URL segment to a collection. It is unique per store, so
            it returns at most one row, and it is the only identifier an integrator
            holding a live URL has. The route itself keys on the immutable id, because a
            handle is rewritten whenever a merchant renames a collection.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, archived] }
          description: >-
            Narrows to live or archived collections. ARCHIVED ARE INCLUDED BY DEFAULT and
            that is not changing: withdrawing them from the unfiltered page would silently
            alter what every deployed integration receives. An unrecognised value is a 400
            rather than an empty page, which would otherwise read as "this merchant has no
            collections".
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Collections touched at or after this RFC3339 instant, for reconciling
            incrementally instead of re-reading the whole list. Supplying it CHANGES THE
            ORDER of this endpoint: the default walk descends created_at, and this one
            climbs updated_at, because that is the only ordering in which a keyset walk
            over a column the merchant keeps rewriting cannot skip a row. A collection
            edited mid-walk moves forward past your position and is returned again, so the
            walk is AT LEAST ONCE: apply the state of each row you receive and a repeat
            costs nothing, while a miss would be a storefront category silently diverging
            from what the merchant set. The bound is INCLUSIVE, so resuming from the newest
            updated_at you hold re-delivers the rows sharing that instant rather than
            dropping the ones your last page did not reach. A cursor belongs to ONE
            ordering and is refused by the other, since the same position means something
            different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        archived_at:
                          nullable: true
                        created_at:
                          type: string
                        description:
                          nullable: true
                        handle:
                          type: string
                        id:
                          type: string
                        metadata:
                          type: object
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a collection
      operationId: post_collections
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle, title]
              additionalProperties: false
              properties:
                handle:
                  type: string
                  description: >-
                    The storefront URL segment. Unique per store, and a live link:
                    changing it later breaks any URL already shared.
                title:
                  type: string
                description:
                  type: [string, "null"]
                metadata:
                  type: object
                  description: The merchant's own free-form bag.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections/{collectionId}:
    delete:
      summary: Delete a collection
      operationId: delete_collections_by_collectionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections:delete
      x-required-scopes: [write_collections, write_collections:delete]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_collections and then on write_collections:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_collections, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a collection
      operationId: get_collections_by_collectionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_collections
      x-required-scopes: [read_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a collection
      operationId: patch_collections_by_collectionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional. An OMITTED field is left unchanged; a field
                sent as null CLEARS it where the column allows null. Those are
                different requests and a partial update must not clear what it omits.
              properties:
                handle:
                  type: string
                title:
                  type: string
                description:
                  type: [string, "null"]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections/{collectionId}/archive:
    post:
      summary: Remove a collection from the storefront without deleting it
      operationId: post_collections_by_collectionId_archive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections/{collectionId}/metadata:
    put:
      summary: Replace a collection's metadata document
      operationId: put_collections_by_collectionId_metadata
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [metadata]
              additionalProperties: false
              properties:
                expected_updated_at:
                  type: string
                  format: date-time
                  description: >-
                    OPTIONAL precondition. Because this replaces the WHOLE document, the
                    instruction below (read the current value and send it back with your
                    change) is a read-modify-write race unless you assert a version: the
                    loser silently drops the winner's keys and receives a 200, and one of
                    those keys is the storefront tile's cover image. Send back the
                    updated_at you read and a stale write is refused 409
                    collection_modified. Omit it and the write behaves exactly as it
                    always has; it is optional because this route shipped without it.
                    Compared at the precision you express, so a millisecond timestamp
                    satisfies a microsecond one.
                metadata:
                  type: object
                  description: >-
                    REPLACES the whole document rather than merging into it. Read the
                    current value first and send it back with your change, or keys the
                    merchant set elsewhere are dropped.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections/{collectionId}/products:
    get:
      summary: List a collection's products
      operationId: get_collections_by_collectionId_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_collections
      x-required-scopes: [read_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        product_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add or remove products in a collection
      operationId: post_collections_by_collectionId_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                One call carries both sides so a reconciler converges in a single
                request. An id may not appear in both lists. The response is a count
                summary: added and already_present distinguish a first run from a
                converged one.
              properties:
                add:
                  type: array
                  items: { type: string }
                remove:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      added:
                        type: integer
                      already_present:
                        type: integer
                      collection_id:
                        type: string
                      not_present:
                        type: integer
                      removed:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/collections/{collectionId}/unarchive:
    post:
      summary: Restore an archived collection to the storefront
      operationId: post_collections_by_collectionId_unarchive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_collections
      x-required-scopes: [write_collections]
      parameters:
        - name: collectionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      archived_at:
                        nullable: true
                      created_at:
                        type: string
                      description:
                        type: string
                      handle:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_collections scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/commerce-schema:
    get:
      summary: Retrieve commerce schema
      operationId: get_commerce_schema
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      filters:
                        type: array
                        items:
                          type: string
                      kinds:
                        type: object
                        properties:
                          404:
                            type: array
                            items:
                              type: string
                          about:
                            type: array
                            items:
                              type: string
                          account:
                            type: array
                            items:
                              type: string
                          cart:
                            type: array
                            items:
                              type: string
                          checkout:
                            type: array
                            items:
                              type: string
                          collection:
                            type: array
                            items:
                              type: string
                          contact:
                            type: array
                            items:
                              type: string
                          footer:
                            type: array
                            items:
                              type: string
                          header:
                            type: array
                            items:
                              type: string
                          home:
                            type: array
                            items:
                              type: string
                          login:
                            type: array
                            items:
                              type: string
                          order:
                            type: array
                            items:
                              type: string
                          product:
                            type: array
                            items:
                              type: string
                          search:
                            type: array
                            items:
                              type: string
                      loop_vars:
                        type: object
                        properties:
                          cart.items:
                            type: string
                          collection.products:
                            type: string
                          line_item.properties:
                            type: string
                          order.line_items:
                            type: string
                          product.options_with_values:
                            type: string
                          product.variants:
                            type: string
                          product_option.values:
                            type: string
                          search.results:
                            type: string
                      objects:
                        type: object
                        properties:
                          about:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  description:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  heading:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  paragraphs:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      type:
                                        type: string
                                  values:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      type:
                                        type: string
                          cart:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  currency:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  item_count:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  items:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  items_subtotal_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  total_discount:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  total_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                          collection:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  description:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  featured_image:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  handle:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  image:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  products:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  products_count:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          contact:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  address:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  description:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  email:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  email_url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  form_description:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  form_heading:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  heading:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  phone:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          customer:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  email:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  first_name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  last_name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          line_item:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  final_line_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  final_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  image:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  key:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  original_line_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  original_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  product_title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  properties:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  quantity:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  variant_id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  variant_title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  vendor:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          line_property:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  value:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          order:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  created_at:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  currency:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  email:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  item_count:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  line_items:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  order_number:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  shipping_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  status:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  subtotal_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  tax_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                          product:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  available:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  compare_at_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  compare_at_price_varies:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  currency:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  description:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  featured_image:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  gift_card:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  handle:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  has_only_default_variant:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  images:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      type:
                                        type: string
                                  metafields:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  options_with_values:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  price_max:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  price_min:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  price_varies:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  quantity_price_breaks_configured:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  selected_or_first_available_variant:
                                    type: object
                                    properties:
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  variants:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  variants_count:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  vendor:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          product_option:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  position:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  selected_value:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  values:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                          product_option_value:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  available:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  selected:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          search:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  performed:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  results:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      object:
                                        type: string
                                      type:
                                        type: string
                                  results_count:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  terms:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          shop:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  currency:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  domain:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  name:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  secure_url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  url:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                          variant:
                            type: object
                            properties:
                              properties:
                                type: object
                                properties:
                                  available:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  compare_at_price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  id:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  image:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  inventory_quantity:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  options:
                                    type: object
                                    properties:
                                      list:
                                        type: boolean
                                      type:
                                        type: string
                                  price:
                                    type: object
                                    properties:
                                      money:
                                        type: boolean
                                      type:
                                        type: string
                                  sku:
                                    type: object
                                    properties:
                                      type:
                                        type: string
                                  title:
                                    type: object
                                    properties:
                                      type:
                                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies:
    get:
      summary: List companies
      operationId: get_companies
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, inactive] }
          description: >-
            The vocabulary is CLOSED and an unknown value is a 400, not an empty page: a
            client sending ?status=enabled and quietly receiving nothing would read it as
            "this merchant has no accounts".
        - name: external_id
          in: query
          required: false
          schema: { type: string }
          description: The company carrying one identifier from your own system.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Companies touched at or after this RFC3339 instant. STATUS gates whether a
            company's buyers can order at all, and credit terms and contacts are edited
            throughout the relationship, while created_at is written once at onboarding.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        external_id:
                          type: [string, "null"]
                        id:
                          type: string
                        metadata:
                          type: object
                          properties:
                            account_manager:
                              type: string
                        name:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a B2B company account
      operationId: post_companies
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                CONVERGENCE PAST THE IDEMPOTENCY WINDOW DEPENDS ON external_id. A company
                created WITH one is protected by a UNIQUE (store_id, external_id) index, so
                a nightly sync re-running an interrupted push gets 409 external_id_taken
                rather than a second account for one customer. A company created WITHOUT
                one has no natural key, so a retry past the window mints a duplicate.
                Reconcile by listing with ?external_id=, or send your own key.
                Locations and contacts are created through their own nested routes and are
                refused here by name.
              properties:
                name:
                  type: string
                  description: >-
                    Required and must not be blank. The account's legal or trading name.
                    Not unique: a merchant may hold two accounts under one name.
                external_id:
                  type: [string, "null"]
                  description: >-
                    YOUR key from the ERP or CRM that owns this list, not ours. Unique per
                    store when present, which is what makes a re-run of an interrupted sync
                    converge. A blank or whitespace value is normalised to absent rather
                    than stored, so an empty spreadsheet cell does not occupy the index and
                    collide with the next such row.
                status:
                  type: string
                  enum: [active, inactive]
                  description: >-
                    Optional, defaults to active. inactive is the REVERSIBLE withdrawal:
                    it stops an account trading and is undone with one PATCH, which is why
                    an account-sync integration never needs the delete qualifier.
                metadata:
                  type: object
                  additionalProperties: true
                  description: >-
                    Your own free-form bag. It REPLACES wholesale rather than merging, so a
                    client can delete a key. Send {} to empty it: null is refused, because
                    the column is NOT NULL and accepting null would be a second spelling of
                    the same act.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      external_id:
                        type: [string, "null"]
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          account_manager:
                            type: string
                      name:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies/{companyId}:
    delete:
      summary: Delete a B2B company account and everything under it
      description: >-
        Removes the company account AND EVERYTHING UNDER IT. This is a HARD delete and it
        CASCADES: every location and every contact belonging to the company goes with it,
        in the same transaction, with no confirmation step and no way to recover them
        through this API. Nothing else in the schema references a company, so the blast
        radius is exactly that hierarchy, but it is larger than the URL suggests and it
        is stated here rather than discovered.
        COUNT THE CHILDREN FIRST if you need to know what you are about to remove. GET
        /api/v1/companies/{companyId}/locations and /contacts both page, and this route
        answers 204 with no body, so the counts cannot come back with the response.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_companies alone, so ask the merchant for both.
        A CROSS-TENANT ID IS A 404, never a 403: the delete is scoped to the store, so
        another merchant's company id matches nothing and cannot be probed for existence.
      operationId: delete_companies_by_companyId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies:delete
      x-required-scopes: [write_companies, write_companies:delete]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_companies and then on write_companies:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_companies, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a company
      operationId: get_companies_by_companyId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      external_id:
                        type: [string, "null"]
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          account_manager:
                            type: string
                      name:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a B2B company account
      operationId: patch_companies_by_companyId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value; an explicit null on external_id
                CLEARS it. A PUT would make an integration that computed a partial body
                blank external_id and get a 200 for it, and the next sync run would then
                create a duplicate account because its own key no longer resolves. A patch
                naming no field at all is a 400 rather than a no-op, because a bumped
                updated_at would make every incremental sync re-pull the row forever.
              properties:
                name:
                  type: string
                  description: Must not be blank. Omit the key to leave it unchanged.
                external_id:
                  type: [string, "null"]
                  description: >-
                    Send null to clear it, which is the real operation when an account moves
                    out of the system that owned it. Omit the key to leave it alone.
                status:
                  type: string
                  enum: [active, inactive]
                  description: The reversible withdrawal. Undone by patching it back to active.
                metadata:
                  type: object
                  additionalProperties: true
                  description: Replaces the stored bag wholesale. Send {} to empty it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      external_id:
                        type: [string, "null"]
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          account_manager:
                            type: string
                      name:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies/{companyId}/contacts:
    get:
      summary: List a company's contacts
      operationId: get_companies_by_companyId_contacts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        company_id:
                          type: string
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        id:
                          type: string
                        is_primary:
                          type: boolean
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Authorise an existing customer to buy on a company account
      operationId: post_companies_by_companyId_contacts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id]
              additionalProperties: false
              description: >-
                THIS ATTACHES AN EXISTING PERSON AND CANNOT CREATE ONE. customer_id is a
                pointer the server resolves inside your store; a customer that does not
                exist there is 422, never a silently created record. The person's own
                fields (email, first_name, last_name, phone, locale) are refused BY NAME
                rather than answered "unknown field", because they are real things about
                this person that this route deliberately will not touch: creating or
                editing a shopper is the customers API.
                A customer already attached to this company is 409 contact_exists, because
                a person linked twice would be two authorities the merchant has to revoke
                separately and revoking one would look like it worked.
                location_ids is refused: per-location ordering authority is not modelled
                yet, so a contact is attached to the company as a whole.
              properties:
                customer_id:
                  type: string
                  description: >-
                    Required. The id of an EXISTING customer in this store, as returned by
                    GET /api/v1/customers. It is the only thing this API publishes about the
                    person: resolve their name and email through the customers API with
                    read_customers.
                is_primary:
                  type: boolean
                  description: >-
                    Optional, defaults to false. Setting it true DEMOTES whichever contact
                    currently holds the flag, in the same transaction, because at most one
                    primary is allowed per company.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      company_id:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      is_primary:
                        type: boolean
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies/{companyId}/contacts/{companyContactId}:
    delete:
      summary: Revoke a contact's authority to buy on a company account
      operationId: delete_companies_by_companyId_contacts_by_companyContactId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies:delete
      x-required-scopes: [write_companies, write_companies:delete]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyContactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_companies and then on write_companies:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_companies, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a company contact
      operationId: get_companies_by_companyId_contacts_by_companyContactId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyContactId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      company_id:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      is_primary:
                        type: boolean
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Change whether a contact is the account's primary buyer
      operationId: patch_companies_by_companyId_contacts_by_companyContactId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyContactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [is_primary]
              additionalProperties: false
              description: >-
                is_primary IS THE ONLY WRITABLE FIELD, and customer_id is refused here where
                the create accepts it. Repointing an existing link at a different person
                would move one buyer's ordering authority to another with a 200 and nothing
                recording that it happened; delete this contact and attach the other
                customer instead, so the change is two visible acts and the removal passes
                through the delete gate.
              properties:
                is_primary:
                  type: boolean
                  description: >-
                    Required: a patch naming nothing else has nothing to do. Setting it true
                    demotes whichever contact currently holds the flag, in the same
                    transaction.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      company_id:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      is_primary:
                        type: boolean
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies/{companyId}/locations:
    get:
      summary: List a company's locations
      operationId: get_companies_by_companyId_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        address_line_1:
                          type: [string, "null"]
                        address_line_2:
                          type: [string, "null"]
                        city:
                          type: [string, "null"]
                        company_id:
                          type: string
                        country_code:
                          type: [string, "null"]
                        created_at:
                          type: string
                        customer_group_id:
                          type: [string, "null"]
                        id:
                          type: string
                        is_default:
                          type: boolean
                        name:
                          type: string
                        payment_terms_days:
                          type: [integer, "null"]
                        phone:
                          type: [string, "null"]
                        postal_code:
                          type: [string, "null"]
                        region:
                          type: [string, "null"]
                        tax_registration_id:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add a location to a company
      operationId: post_companies_by_companyId_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                THE ADDRESS IS ENTIRELY OPTIONAL, deliberately: an ERP push routinely
                creates the org chart before the addresses arrive, and refusing the row
                would mean the hierarchy cannot be synced at all until someone types one. A
                location with no address simply cannot be shipped to.
                company_id is refused here by name because it comes from the path, and
                price_list_id is refused because a location reaches pricing by naming a
                CUSTOMER GROUP, which a price list already targets. There is no second
                targeting path, so a price_list_id here would have had no effect on any
                cart and no error either.
              properties:
                name:
                  type: string
                  description: >-
                    Required and must not be blank. Your label for the site, such as
                    "Head Office" or "Bristol Depot".
                customer_group_id:
                  type: [string, "null"]
                  description: >-
                    THE NEGOTIATED-PRICING POINTER, and the one field on this family that
                    costs a second scope. Naming it, or clearing it with null, decides what
                    every buyer at this location is charged, so it requires write_pricing IN
                    ADDITION TO write_companies and answers 403 insufficient_scope without
                    both. The id must name a customer group in THIS store; anything else is
                    422, because the database foreign key does not enforce the tenant.
                    Null means no negotiated pricing, which is the ordinary state of a
                    location buying at the store's list prices.
                address_line_1:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                address_line_2:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                city:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                region:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                postal_code:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                country_code:
                  type: [string, "null"]
                  description: >-
                    Optional. Stored and served exactly as sent, with no case
                    normalisation, so a read never disagrees with the write that preceded
                    it.
                phone:
                  type: [string, "null"]
                  description: Optional. Clearable with null.
                tax_registration_id:
                  type: [string, "null"]
                  description: >-
                    The BUYER's own registration (VAT, GSTIN, ABN). Per location rather than
                    per company, because a group trading in two countries registers
                    separately in each.
                payment_terms_days:
                  type: [integer, "null"]
                  minimum: 0
                  description: >-
                    NULL AND ZERO ARE DIFFERENT ANSWERS AND THIS API KEEPS THEM APART. Null
                    means no terms have been agreed; 0 means DUE ON RECEIPT. Omitting the
                    key leaves it null rather than defaulting to 0, because a default would
                    state a term nobody negotiated and put every unconfigured account on the
                    strictest one. Negative values are a 400.
                is_default:
                  type: boolean
                  description: >-
                    Optional, defaults to false. Setting it true DEMOTES whichever location
                    currently holds the flag, in the same transaction, because at most one
                    default is allowed per company. It therefore changes a location this
                    call never named.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: [string, "null"]
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: [string, "null"]
                      company_id:
                        type: string
                      country_code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      customer_group_id:
                        type: [string, "null"]
                      id:
                        type: string
                      is_default:
                        type: boolean
                      name:
                        type: string
                      payment_terms_days:
                        type: [integer, "null"]
                      phone:
                        type: [string, "null"]
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      tax_registration_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/companies/{companyId}/locations/{companyLocationId}:
    delete:
      summary: Delete a company location
      operationId: delete_companies_by_companyId_locations_by_companyLocationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies:delete
      x-required-scopes: [write_companies, write_companies:delete]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyLocationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_companies and then on write_companies:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_companies, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a company location
      operationId: get_companies_by_companyId_locations_by_companyLocationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_companies
      x-required-scopes: [read_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyLocationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: [string, "null"]
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: [string, "null"]
                      company_id:
                        type: string
                      country_code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      customer_group_id:
                        type: [string, "null"]
                      id:
                        type: string
                      is_default:
                        type: boolean
                      name:
                        type: string
                      payment_terms_days:
                        type: [integer, "null"]
                      phone:
                        type: [string, "null"]
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      tax_registration_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a company location
      operationId: patch_companies_by_companyId_locations_by_companyLocationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_companies
      x-required-scopes: [write_companies]
      parameters:
        - name: companyId
          in: path
          required: true
          schema: { type: string }
        - name: companyLocationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value and an explicit null clears the
                column. The merge starts from the STORED row, so a patch that only corrects
                a postcode carries customer_group_id, the payment terms and the tax
                registration forward untouched.
                customer_group_id is the exception worth reading twice: naming it OR
                sending null for it requires write_pricing in addition to write_companies,
                because clearing the pointer drops this location back to the store's
                ordinary prices, and removing a negotiated rate is a repricing just as
                surely as setting one. A patch that does not mention the key needs no
                pricing scope at all, so an address correction from a logistics integration
                is never refused for a reason that has nothing to do with addresses.
              properties:
                name:
                  type: string
                  description: Must not be blank. Omit the key to leave it unchanged.
                customer_group_id:
                  type: [string, "null"]
                  description: >-
                    Requires write_pricing in addition to write_companies, whether it is
                    being set or cleared. Must name a customer group in this store.
                address_line_1:
                  type: [string, "null"]
                  description: Send null to clear it.
                address_line_2:
                  type: [string, "null"]
                  description: Send null to clear it.
                city:
                  type: [string, "null"]
                  description: Send null to clear it.
                region:
                  type: [string, "null"]
                  description: Send null to clear it.
                postal_code:
                  type: [string, "null"]
                  description: Send null to clear it.
                country_code:
                  type: [string, "null"]
                  description: Send null to clear it.
                phone:
                  type: [string, "null"]
                  description: Send null to clear it.
                tax_registration_id:
                  type: [string, "null"]
                  description: Send null to clear it.
                payment_terms_days:
                  type: [integer, "null"]
                  minimum: 0
                  description: >-
                    Send null to record that no terms are agreed and 0 to record due on
                    receipt. The two are different commercial facts.
                is_default:
                  type: boolean
                  description: >-
                    Setting it true demotes whichever location currently holds the flag, in
                    the same transaction.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: [string, "null"]
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: [string, "null"]
                      company_id:
                        type: string
                      country_code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      customer_group_id:
                        type: [string, "null"]
                      id:
                        type: string
                      is_default:
                        type: boolean
                      name:
                        type: string
                      payment_terms_days:
                        type: [integer, "null"]
                      phone:
                        type: [string, "null"]
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      tax_registration_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_companies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/currencies:
    get:
      summary: List currencies
      operationId: get_currencies
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_currencies
      x-required-scopes: [read_currencies]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        decimal_digits:
                          type: integer
                        id:
                          type: string
                        is_default:
                          type: boolean
                        name:
                          type: string
                        name_plural:
                          type: string
                        symbol:
                          type: string
                        symbol_native:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_currencies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/currencies/{currencyCode}:
    get:
      summary: Retrieve a currency
      operationId: get_currencies_by_currencyCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_currencies
      x-required-scopes: [read_currencies]
      parameters:
        - name: currencyCode
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      decimal_digits:
                        type: integer
                      id:
                        type: string
                      is_default:
                        type: boolean
                      name:
                        type: string
                      name_plural:
                        type: string
                      symbol:
                        type: string
                      symbol_native:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_currencies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customer-groups:
    get:
      summary: List customer groups
      operationId: get_customer_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Groups touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the whole list.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE:
            apply the state of each row you receive and a repeat costs nothing. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant rather than dropping the ones your last page did not reach.
            A cursor belongs to ONE ordering and is refused by the other.
            THE ONE CAVEAT: membership lives in a join table that carries no timestamp, so
            ADDING OR REMOVING A CUSTOMER does not move the group in this walk. Only editing
            the group itself does; reconcile membership from the members sub-list.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        metadata:
                          type: object
                        name:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a customer group
      operationId: post_customer_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers
      x-required-scopes: [write_customers]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              properties:
                name:
                  type: string
                  description: >-
                    The group's display name. Merchant-chosen, not unique, and not the
                    id used to add or remove members.
                metadata:
                  type: [object, "null"]
                  description: >-
                    Free-form key/value data. Published unchanged on the read: this
                    write and the dashboard's own create call the same service
                    method, so nothing new touches the column.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customer-groups/{customerGroupId}:
    get:
      summary: Retrieve a customer group
      operationId: get_customer_groups_by_customerGroupId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: customerGroupId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Rename a customer group or update its metadata
      operationId: patch_customer_groups_by_customerGroupId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers
      x-required-scopes: [write_customers]
      parameters:
        - name: customerGroupId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                At least one field is required; an empty object is refused rather than
                treated as a no-op. name and metadata are each optional independently.
              properties:
                name:
                  type: string
                metadata:
                  type: [object, "null"]
                  description: >-
                    THREE STATES, all distinct: omit the key to leave the stored bag
                    untouched, send null or {} to clear it, send an object to replace
                    it wholesale. There is no per-key merge.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customer-groups/{customerGroupId}/customers:
    get:
      summary: List a customer group's customers
      description: >-
        The customers in one group, paged. This is where membership lives, and the group
        resource deliberately carries no members array: a group with 50,000 buyers would
        otherwise serialise 50,000 ids into one row of a list response, on a resource
        whose page cap says nothing about how many people it names.
        THE OTHER HALF OF THE B2B SYNC PATH is GET /price-lists/{priceListId}/customer-
        groups. Together they are price list to groups to customers, which is how a
        wholesale integration learns which buyers get which negotiated prices. Neither leg
        is useful without the other.
        THE ROWS ARE THE SAME CUSTOMER RESOURCE /customers serves, with the same fields and
        the same nullability. One customer has one shape whichever route found them, so a
        client can hand a row here to the same parser it uses there.
        CURSOR ONLY, no offset and no total. A merchant tagging customers inserts rows into
        this membership continuously, and an offset walk drops or repeats one on every page
        turn; here a dropped row is a buyer who silently stops receiving wholesale prices.
        AN EMPTY GROUP IS A 200 WITH AN EMPTY PAGE. A group id that does not exist, or
        belongs to another store, is a 404. The two are distinguished deliberately: a 200
        for a mistyped id would record "this tier has no buyers" and apply retail prices
        to everyone in it.
        MEMBERSHIP IS READ-ONLY HERE. Nothing on this API adds or removes a member, for the
        reason the group family publishes no writes at all: a group's price-list targeting
        rows cascade with it, and a price list left untargeted applies to EVERY customer.
      operationId: get_customer_groups_by_customerGroupId_customers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: customerGroupId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        first_name:
                          type: [string, "null"]
                        id:
                          type: string
                        last_name:
                          type: [string, "null"]
                        phone:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customers:
    get:
      summary: List customers
      description: >-
        The store's customers, newest first.
        ?updated_after= selects the same ascending updated_at sync walk the product list
        describes, with the same cursor rule: a cursor belongs to one ordering and is
        refused by the other. ?email= resolves the identifier most external systems key a
        shopper on, matched case-insensitively.
      operationId: get_customers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: email
          in: query
          required: false
          schema: { type: string }
          description: >-
            One shopper by the identifier every external system keys them on, in one call rather
            than a walk of the whole customer list. An EMPTY value is a 400 rather than "no
            filter": it would otherwise hand back every customer in the store to a caller that
            believes it holds one.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Customers touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the list. It does NOT filter this list: supplying it SELECTS A
            DIFFERENT ORDERING. The default walk descends created_at; this one climbs updated_at,
            because that is the only ordering in which a keyset walk over a mutable column cannot
            skip a row. A customer edited mid-walk moves forward past your position and is
            returned again, so the walk is AT LEAST ONCE. A cursor from one ordering is refused by
            the other, since the same position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        first_name:
                          type: [string, "null"]
                        id:
                          type: string
                        last_name:
                          type: [string, "null"]
                        phone:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a customer
      operationId: post_customers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers
      x-required-scopes: [write_customers]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              additionalProperties: false
              description: >-
                email identifies the account. A create for an email that already exists
                does NOT mint a second customer, because two records for one person
                split their order history permanently.
              properties:
                email:
                  type: string
                first_name:
                  type: [string, "null"]
                last_name:
                  type: [string, "null"]
                phone:
                  type: [string, "null"]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      phone:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customers/{customerId}:
    delete:
      summary: Erase a customer's personal data, retaining the order record
      operationId: delete_customers_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers:delete
      x-required-scopes: [write_customers, write_customers:delete]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      erased:
                        type: object
                        properties:
                          addresses:
                            type: integer
                          agent_conversation_emails:
                            type: integer
                          cart_emails:
                            type: integer
                          draft_order_emails:
                            type: integer
                          identifiers:
                            type: boolean
                      id:
                        type: string
                      redacted_at:
                        type: string
                      retained:
                        type: object
                        properties:
                          orders:
                            type: integer
                          retention_basis:
                            type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_customers and then on write_customers:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_customers, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a customer
      operationId: get_customers_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      phone:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Correct a customer's name or phone number
      operationId: patch_customers_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers
      x-required-scopes: [write_customers]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                The only writable fields are these three. email is the login
                identifier and is never settable here; password, addresses,
                groups and notes are not on this API at all. A field sent as null
                CLEARS it; a blank string is refused so there is only one spelling
                for absent.
              properties:
                first_name:
                  type: [string, "null"]
                  maxLength: 255
                last_name:
                  type: [string, "null"]
                  maxLength: 255
                phone:
                  type: [string, "null"]
                  maxLength: 32
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      phone:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customers/{customerId}/addresses:
    get:
      summary: List a customer's addresses
      description: >-
        One customer's saved address book, newest first.
        THIS IS WHERE AN ADDRESS ID COMES FROM. It is published nowhere else on this API:
        the customer read carries no addresses, an order's address is a snapshot with no
        id, the customers/created webhook withholds them, and the erasure route reports a
        count. DELETE /api/v1/customers/{customerId}/addresses/{addressId} takes an id
        from this page and nowhere else.
        A SUB-RESOURCE rather than a field on the customer, deliberately: a page of a
        hundred customers would otherwise carry every postal address in the store on a
        read whose caller usually wants names.
        A CUSTOMER THAT DOES NOT EXIST IS A 404, not an empty page. "This shopper has no
        addresses" and "this shopper does not exist" lead to opposite next actions, and
        another store's customer answers the same 404 so an id cannot be probed.
        THIS READ IS AUDITED. It is the most sensitive thing read_customers exports, so
        the trail records that it happened and at what scale, never the addresses.
      operationId: get_customers_by_customerId_addresses
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_customers
      x-required-scopes: [read_customers]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        address_1:
                          type: string
                        address_2:
                          nullable: true
                        city:
                          type: string
                        company:
                          nullable: true
                        country_code:
                          type: string
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        first_name:
                          type: [string, "null"]
                        id:
                          type: string
                        is_default_billing:
                          type: boolean
                        is_default_shipping:
                          type: boolean
                        label:
                          type: string
                        last_name:
                          type: [string, "null"]
                        phone:
                          type: [string, "null"]
                        postal_code:
                          type: string
                        province:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/customers/{customerId}/addresses/{addressId}:
    delete:
      summary: Delete a customer's address
      operationId: delete_customers_by_customerId_addresses_by_addressId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_customers
      x-required-scopes: [write_customers]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: addressId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. TENANT AND PARENT
                ARE BOTH ENFORCED: the delete is scoped on (id, store_id, customer_id)
                together, so an address id that is real but belongs to a different
                customer, or a different store, answers the same 404 an id that never
                existed does. THERE IS NO RESTORE.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_customers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products:
    get:
      summary: List digital products
      operationId: get_digital_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: fulfillment_type
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this fulfillment_type. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: variant_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this variant_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        default_expires_after_days:
                          type: [integer, "null"]
                        fulfillment_type:
                          type: string
                        id:
                          type: string
                        max_downloads:
                          type: [integer, "null"]
                        reveal_mode:
                          type: string
                        subscription_mode:
                          type: string
                        subscription_period_days:
                          type: [integer, "null"]
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Make a variant a digital product
      operationId: post_digital_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, fulfillment_type]
              additionalProperties: false
              description: >-
                Attaches a fulfilment rule to an EXISTING variant. It does not create a
                product: use POST /api/v1/products first. A variant may carry one rule,
                so a second create on the same variant is 409 config_exists rather than
                an upsert, because silently rewriting the terms would change what buyers
                already hold entitlements under.
              properties:
                variant_id:
                  type: string
                  description: The catalogue variant this rule applies to.
                fulfillment_type:
                  type: string
                  enum: [file, license_key, subscription]
                  description: >-
                    file delivers a download, license_key claims a key from a pool, and
                    subscription grants time-boxed access.
                file_object_key:
                  type: string
                  writeOnly: true
                  description: >-
                    Required for fulfillment_type file. It names an object already
                    uploaded into this store's namespace (stores/{store_id}/...); a key
                    outside it is refused. WRITE-ONLY: neither this 201 nor any later
                    read returns it, because a published object key is a capability into
                    the merchant's bucket with no expiry and no revocation. That means
                    you cannot verify from this API which object a paid download is
                    attached to, so RECORD THE KEY YOU SENT against the config id in your
                    own system; there is no route that will tell you afterwards.
                max_downloads:
                  type: integer
                  description: >-
                    Per-buyer download allowance, snapshotted onto each entitlement at
                    grant time. Omit for unlimited; 0 is refused rather than treated as
                    unlimited.
                default_expires_after_days:
                  type: integer
                  description: >-
                    Access window in days, stamped onto each entitlement's expires_at at
                    grant time. Omit for access that does not lapse.
                subscription_period_days:
                  type: integer
                  description: Required for a manual subscription. Ignored otherwise.
                reveal_mode:
                  type: string
                  enum: [multi, once]
                  description: >-
                    Whether a buyer may re-reveal a license key plaintext or gets one
                    window. Defaults to multi. Fixed at creation.
                subscription_mode:
                  type: string
                  enum: [manual]
                  description: >-
                    Only manual is accepted here. A stripe-mode product needs a
                    recurring_price_id on the merchant's own Stripe account, which this
                    API holds no authority over, so the request is refused rather than
                    half-completed; create that product in the dashboard.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      default_expires_after_days:
                        type: [integer, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      max_downloads:
                        type: [integer, "null"]
                      reveal_mode:
                        type: string
                      subscription_mode:
                        type: string
                      subscription_period_days:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/entitlements:
    get:
      summary: List digital product entitlements
      operationId: get_digital_products_entitlements
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this customer_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        download_count:
                          type: integer
                        download_limit:
                          type: [integer, "null"]
                        expires_at:
                          type: [string, "null"]
                        fulfillment_type:
                          type: string
                        id:
                          type: string
                        line_item_id:
                          type: [string, "null"]
                        order_id:
                          type: [string, "null"]
                        quantity:
                          type: integer
                        status:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/entitlements/{entitlementId}:
    get:
      summary: Retrieve a digital product entitlement
      operationId: get_digital_products_entitlements_by_entitlementId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: entitlementId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      download_count:
                        type: integer
                      download_limit:
                        type: [integer, "null"]
                      expires_at:
                        type: [string, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      line_item_id:
                        type: [string, "null"]
                      order_id:
                        type: [string, "null"]
                      quantity:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/entitlements/{entitlementId}/reset-downloads:
    post:
      summary: Restore a buyer's download allowance
      operationId: post_digital_products_entitlements_by_entitlementId_reset_downloads
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: entitlementId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Zeroes download_count
                for the support case of a download that failed halfway and burned an
                attempt. It does NOT extend access: expires_at is untouched, so a lapsed
                entitlement still reports status expired afterwards, and the allowance
                restored is the download_limit snapshotted at grant time rather than a
                number the caller chooses.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      download_count:
                        type: integer
                      download_limit:
                        type: [integer, "null"]
                      expires_at:
                        type: [string, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      line_item_id:
                        type: [string, "null"]
                      order_id:
                        type: [string, "null"]
                      quantity:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/entitlements/{entitlementId}/revoke:
    post:
      summary: Revoke a buyer's entitlement
      operationId: post_digital_products_entitlements_by_entitlementId_revoke
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: entitlementId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Marks the entitlement
                revoked and revokes its bound license key in the same transaction, so the
                key stops validating at the same instant access does. Nothing is deleted:
                the row and its audit trail survive a dispute months later. Idempotent by
                construction as well as by Idempotency-Key, so revoking an
                already-revoked entitlement returns the same body.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      download_count:
                        type: integer
                      download_limit:
                        type: [integer, "null"]
                      expires_at:
                        type: [string, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      line_item_id:
                        type: [string, "null"]
                      order_id:
                        type: [string, "null"]
                      quantity:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/{configId}:
    delete:
      summary: Delete a digital product's fulfilment rule
      operationId: delete_digital_products_by_configId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products:delete
      x-required-scopes: [write_digital_products, write_digital_products:delete]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_digital_products and then on write_digital_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_digital_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a digital product
      operationId: get_digital_products_by_configId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      default_expires_after_days:
                        type: [integer, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      max_downloads:
                        type: [integer, "null"]
                      reveal_mode:
                        type: string
                      subscription_mode:
                        type: string
                      subscription_period_days:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a digital product's fulfilment terms
      operationId: patch_digital_products_by_configId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted field is left unchanged, and the merged result is revalidated,
                so switching to fulfillment_type file without a file_object_key is
                refused rather than producing a product with nothing behind it. The
                variant, the reveal mode, the default expiry and the subscription mode
                are NOT settable: each would change the terms of entitlements already
                granted, and none is reversible from the buyer's side.
              properties:
                fulfillment_type:
                  type: string
                  enum: [file, license_key, subscription]
                file_object_key:
                  type: string
                  writeOnly: true
                  description: >-
                    Must be in this store's namespace. WRITE-ONLY and never returned on
                    a read, so a patch that repoints a paid download cannot be confirmed
                    through this API: keep your own record of what you sent.
                max_downloads:
                  type: integer
                subscription_period_days:
                  type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      default_expires_after_days:
                        type: [integer, "null"]
                      fulfillment_type:
                        type: string
                      id:
                        type: string
                      max_downloads:
                        type: [integer, "null"]
                      reveal_mode:
                        type: string
                      subscription_mode:
                        type: string
                      subscription_period_days:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/{configId}/files:
    get:
      summary: List a config's files
      operationId: get_digital_products_by_configId_files
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        config_id:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        mime_type:
                          type: [string, "null"]
                        name:
                          type: string
                        size_bytes:
                          type: [integer, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Register a deliverable file
      operationId: post_digital_products_by_configId_files
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, file_key]
              additionalProperties: false
              description: >-
                Registers an object already uploaded to storage; it does not upload one.
                The response carries the file WITHOUT its key, which is the shape every
                read serves.
              properties:
                name:
                  type: string
                  description: >-
                    What the buyer sees, and unique within the product. Every upload is
                    presigned a fresh object key, so the name is the only thing that can
                    tell a re-upload from a second file; a duplicate is 409 file_exists.
                file_key:
                  type: string
                  writeOnly: true
                  description: >-
                    The storage object, which must be in this store's namespace
                    (stores/{store_id}/...). WRITE-ONLY: accepted on the way in and never
                    returned on the way out, on this 201 or on any read, so keep your own
                    record of which object you registered under which file id.
                mime_type:
                  type: string
                size_bytes:
                  type: integer
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      config_id:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      mime_type:
                        type: [string, "null"]
                      name:
                        type: string
                      size_bytes:
                        type: [integer, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/{configId}/files/{fileId}:
    delete:
      summary: Remove a deliverable file
      operationId: delete_digital_products_by_configId_files_by_fileId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products:delete
      x-required-scopes: [write_digital_products, write_digital_products:delete]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: fileId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_digital_products and then on write_digital_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_digital_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/{configId}/license-key-pools:
    get:
      summary: List a config's license key pools
      operationId: get_digital_products_by_configId_license_key_pools
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_digital_products
      x-required-scopes: [read_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        available_keys:
                          type: [integer, "null"]
                        config_id:
                          type: string
                        created_at:
                          type: string
                        generation_strategy:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        total_keys:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a license key pool
      operationId: post_digital_products_by_configId_license_key_pools
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A pool is created EMPTY. Stock it with the import route, so one request
                never both mints a container and ingests bearer material.
              properties:
                name:
                  type: string
                  description: >-
                    The merchant's label for a batch, typically the vendor and the order
                    it arrived on.
                generation_strategy:
                  type: string
                  enum: [pool, pattern]
                  description: >-
                    pool means the merchant imports its own keys. pattern mints a key on
                    claim from the template below, so such a pool never runs out and its
                    available_keys is served as null rather than 0. Defaults to pool.
                pattern_template:
                  type: string
                  description: >-
                    Required for the pattern strategy. Each X is replaced with a random
                    character from pattern_charset; anything else is a literal. The
                    template must clear a 60-bit keyspace floor, because a minted key is
                    checkable against the public license validation endpoint. Accepted
                    here and never returned on any read.
                pattern_charset:
                  type: string
                  description: >-
                    The alphabet the placeholders draw from. Defaults to a Crockford
                    base32 set that omits the characters people misread.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available_keys:
                        type: [integer, "null"]
                      config_id:
                        type: string
                      created_at:
                        type: string
                      generation_strategy:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      total_keys:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/digital-products/{configId}/license-key-pools/{poolId}/import:
    post:
      summary: Bulk import license keys into a pool
      operationId: post_digital_products_by_configId_license_key_pools_by_poolId_import
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_digital_products
      x-required-scopes: [write_digital_products]
      parameters:
        - name: configId
          in: path
          required: true
          schema: { type: string }
        - name: poolId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys]
              additionalProperties: false
              description: >-
                WRITE-ONLY INGESTION. The keys you send are encrypted at rest and no read
                on this API ever hands one back, so importing teaches a key nothing it
                did not arrive with. The response is two counts and nothing else: it does
                not echo the keys, name the duplicates, or return row ids.
                Re-sending a batch is safe: duplicates dedupe, so a replay reports
                imported 0 and skipped n rather than failing.
              properties:
                keys:
                  type: array
                  items: { type: string }
                  description: >-
                    Up to 10000 plaintexts per request, each at most 512 characters.
                    Duplicates inside the batch and keys already in the pool are counted
                    as skipped rather than refused.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      imported:
                        type: integer
                      skipped:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_digital_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts:
    get:
      summary: List discounts
      operationId: get_discounts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Discounts in one state. An unrecognised value is a 400 naming the accepted set,
            not an empty page: a campaign tool asking which codes are live would otherwise
            send a near-miss like "enabled" or "Active", receive an empty list, and conclude
            the merchant is running no promotions.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Discounts touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the whole list. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, and this one climbs updated_at,
            because that is the only ordering in which a keyset walk over a column the
            merchant keeps rewriting cannot skip a row. THIS IS THE LIST WHERE MISSING AN
            EDIT COSTS MOST: a discount's status, window and budget decide what a shopper is
            charged, so a client that misses a deactivation keeps offering a promotion the
            merchant has already withdrawn. A discount edited mid-walk moves forward past
            your position and is returned again, so the walk is AT LEAST ONCE: apply the
            state of each row you receive and a repeat costs nothing. The bound is INCLUSIVE,
            so resuming from the newest updated_at you hold re-delivers the rows sharing that
            instant rather than dropping the ones your last page did not reach. A cursor
            belongs to ONE ordering and is refused by the other. ARCHIVED DISCOUNTS LEAVE
            THIS WALK SILENTLY, as they leave the default list: it reports what changed, not
            what went away.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: [integer, "null"]
                        code:
                          type: string
                        created_at:
                          type: string
                        currency_code:
                          nullable: true
                        ends_at:
                          nullable: true
                        id:
                          type: string
                        percentage_basis_points:
                          type: [integer, "null"]
                        starts_at:
                          nullable: true
                        status:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                        usage_limit:
                          type: [integer, "null"]
                        used_count:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a disabled discount
      operationId: post_discounts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              additionalProperties: false
              description: >-
                Send exactly one of percentage_basis_points or amount_minor. The
                discount is created DISABLED: a live money rule in front of
                shoppers must not be a side effect of one call, so going live is a
                second, idempotent POST /discounts/{discountId}/enable. A retry
                outside the idempotency window collides on the unique (store, code)
                index and answers 409 code_taken rather than creating a duplicate.
              properties:
                code:
                  type: string
                  description: "Required on create. The natural key a shopper types, and what makes a retry outside the idempotency window a 409 code_taken rather than a second discount. UPPER-CASED AND TRIMMED SERVER-SIDE, so \"apitest-disc-001\" is stored and returned as \"APITEST-DISC-001\": read the code back from the response rather than assuming the bytes you sent, and note that two codes differing only in case are the SAME code and the second is a 409."
                percentage_basis_points:
                  type: integer
                  minimum: 1
                  description: "Sets a percentage discount. Basis points, so 15% is 1500 and no rounding question arises. 1..10000. Mutually exclusive with amount_minor."
                amount_minor:
                  type: integer
                  minimum: 1
                  description: "Sets a fixed discount, in minor units of the store's base currency. Mutually exclusive with percentage_basis_points, and requires currency_code in the same object."
                currency_code:
                  type: string
                  description: "Required with amount_minor and refused without it. An assertion rather than a choice: promotion has no currency column, so this must equal the store's base currency or the write is refused with 409 currency_mismatch."
                starts_at:
                  type: string
                  format: date-time
                ends_at:
                  type: string
                  format: date-time
                usage_limit:
                  type: integer
                  minimum: 1
                  description: "Total redemptions allowed. Omit, or send null on a patch, for unlimited. Zero is refused rather than read as unlimited."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: [integer, "null"]
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        type: [integer, "null"]
                      used_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}:
    delete:
      summary: Delete a discount
      operationId: delete_discounts_by_discountId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts:delete
      x-required-scopes: [write_discounts, write_discounts:delete]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_discounts and then on write_discounts:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_discounts, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a discount
      operationId: get_discounts_by_discountId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: [integer, "null"]
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        type: [integer, "null"]
                      used_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a discount
      operationId: patch_discounts_by_discountId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Every value is an absolute target, so replaying converges. Sending
                a money field REDEFINES what the discount is worth immediately: the
                discount is evaluated at checkout time rather than snapshotted onto
                the cart, so a shopper already in checkout pays the new amount with
                no notification. Status is not settable here; use the enable and
                disable routes.
              properties:
                code:
                  type: string
                  description: "Required on create. The natural key a shopper types, and what makes a retry outside the idempotency window a 409 code_taken rather than a second discount. UPPER-CASED AND TRIMMED SERVER-SIDE, so \"apitest-disc-001\" is stored and returned as \"APITEST-DISC-001\": read the code back from the response rather than assuming the bytes you sent, and note that two codes differing only in case are the SAME code and the second is a 409."
                percentage_basis_points:
                  type: integer
                  minimum: 1
                  description: "Sets a percentage discount. Basis points, so 15% is 1500 and no rounding question arises. 1..10000. Mutually exclusive with amount_minor."
                amount_minor:
                  type: integer
                  minimum: 1
                  description: "Sets a fixed discount, in minor units of the store's base currency. Mutually exclusive with percentage_basis_points, and requires currency_code in the same object."
                currency_code:
                  type: string
                  description: "Required with amount_minor and refused without it. An assertion rather than a choice: promotion has no currency column, so this must equal the store's base currency or the write is refused with 409 currency_mismatch."
                starts_at:
                  type: [string, "null"]
                  format: date-time
                ends_at:
                  type: [string, "null"]
                  format: date-time
                usage_limit:
                  type: [integer, "null"]
                  minimum: 1
                  description: "Total redemptions allowed. Omit, or send null on a patch, for unlimited. Zero is refused rather than read as unlimited."
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: [integer, "null"]
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        type: [integer, "null"]
                      used_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/application-method:
    get:
      summary: Retrieve discount application method
      operationId: get_discounts_by_discountId_application_method
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allocation:
                        type: string
                      amount_minor:
                        type: [integer, "null"]
                      apply_to_quantity:
                        type: [number, "null"]
                      buy_rules_min_quantity:
                        type: [number, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      discount_id:
                        type: string
                      id:
                        type: string
                      max_quantity:
                        type: [number, "null"]
                      percentage_basis_points:
                        type: [integer, "null"]
                      target_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a discount's application method
      operationId: put_discounts_by_discountId_application_method
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type]
              additionalProperties: false
              description: >-
                THE SHAPE OF THE DISCOUNT, and the most dangerous write in this
                family. target_type decides whether the discount comes off the
                ORDER SUBTOTAL or off the SHIPPING, so one call can turn "10% off
                the order" into "10% off shipping"; it has no default for that
                reason. A discount with NO application method still discounts: the
                money path falls back to the discount's own type and value against
                the subtotal. Send exactly one of percentage_basis_points or
                amount_minor. A PUT replaces the whole method and keeps the
                existing row's id, so replaying converges by construction.
              properties:
                target_type:
                  type: string
                  enum: [order, items, shipping_methods]
                  description: "Required, with no default. shipping_methods discounts the SHIPPING and leaves the goods at full price; order and items both discount the subtotal today, because per-line allocation is not implemented. This is the field that decides which total the discount comes off, which is why omitting it is refused rather than defaulted."
                allocation:
                  type: string
                  enum: [across, each]
                  description: "Defaults to across. Stored and published so a client can read back what it set; nothing on the money path branches on it today."
                percentage_basis_points:
                  type: integer
                  minimum: 1
                  description: "Sets a percentage method. Basis points, so 15% is 1500. 1..10000. Mutually exclusive with amount_minor, and a currency_code beside it is refused: a ratio has none."
                amount_minor:
                  type: integer
                  minimum: 1
                  description: "Sets a fixed method, in minor units. Mutually exclusive with percentage_basis_points, and requires currency_code in the same object. On a buy-get method it is PER DISCOUNTED UNIT, not per order."
                currency_code:
                  type: string
                  description: "Required with amount_minor and refused without it. Stored on this table, unlike on the discount itself, but still asserted: it must equal the store's base currency or the write is refused with 409 currency_mismatch, because the amount is subtracted from the cart total without conversion."
                apply_to_quantity:
                  type: integer
                  minimum: 1
                  description: "The SWITCH that makes this a Buy X Get Y method: with it set, the money path computes a buy-get discount and never runs the standard one. Discounted target units granted per trigger. Must be sent together with buy_rules_min_quantity, and needs buy and target rules on the discount, or the offer computes zero."
                buy_rules_min_quantity:
                  type: integer
                  minimum: 1
                  description: "Units matching the discount's buy rules required to trigger the offer. Only read on a buy-get method, so it is refused without apply_to_quantity rather than stored where nothing would read it."
                max_quantity:
                  type: integer
                  minimum: 1
                  description: "Cap on total discounted units. Only read on a buy-get method, so it is refused without apply_to_quantity. Omit for uncapped."
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allocation:
                        type: string
                      amount_minor:
                        type: [integer, "null"]
                      apply_to_quantity:
                        type: [number, "null"]
                      buy_rules_min_quantity:
                        type: [number, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      discount_id:
                        type: string
                      id:
                        type: string
                      max_quantity:
                        type: [number, "null"]
                      percentage_basis_points:
                        type: [integer, "null"]
                      target_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/disable:
    post:
      summary: Disable a discount
      operationId: post_discounts_by_discountId_disable
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The reversible
                operation this family offers in place of a delete: it stops the
                discount applying and destroys nothing. SILENT ON THE MONEY PATH by
                design: a cart already holding the code re-prices to full rather
                than refusing, so disabling a batch at the end of a sale changes
                what shoppers mid-session are charged.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: [integer, "null"]
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        type: [integer, "null"]
                      used_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/enable:
    post:
      summary: Enable a discount
      operationId: post_discounts_by_discountId_enable
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. This is the
                moment the money rule turns on. Absolute despite being a POST:
                enabling an enabled discount leaves it enabled and answers 200, so
                a replay converges. A schedule goes on PATCH /discounts/{discountId}.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: [integer, "null"]
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        type: [integer, "null"]
                      used_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/restore:
    post:
      summary: Restore a deleted discount
      operationId: post_discounts_by_discountId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts:delete
      x-required-scopes: [write_discounts, write_discounts:delete]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Brings back a
                discount removed by DELETE with its eligibility rules and discount
                shape intact, so it is immediately usable rather than a stub. It
                returns in whatever enabled or disabled state it had.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      code:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      percentage_basis_points:
                        type: [integer, "null"]
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      usage_limit:
                        nullable: true
                      used_count:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_discounts and then on write_discounts:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_discounts, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/rules:
    get:
      summary: List a discount's rules
      operationId: get_discounts_by_discountId_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attribute:
                          type: string
                        created_at:
                          type: string
                        description:
                          type: [string, "null"]
                        discount_id:
                          type: string
                        id:
                          type: string
                        kind:
                          type: string
                        operator:
                          type: string
                        updated_at:
                          type: string
                        values:
                          type: array
                          items:
                            type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add an eligibility or buy-get rule to a discount
      operationId: post_discounts_by_discountId_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attribute, operator, values]
              additionalProperties: false
              description: >-
                THIS IS THE GATE. A discount with no eligibility rule applies to
                EVERY cart, because checkout treats an empty rule set as satisfied,
                so a discount created without one is unconditional however it was
                described. Rules AND together. The attribute and operator are
                checked against what the checkout evaluator can actually compute
                and an unevaluable pair is refused rather than stored, because a
                rule the evaluator cannot satisfy is false for every cart and the
                discount then applies to nothing, silently. The rule is live the
                moment it is written: a rule only ever NARROWS a discount, so there
                is no staged state to enable. A retry outside the idempotency
                window writes a SECOND rule, which narrows further rather than
                widening.
              properties:
                kind:
                  type: string
                  enum: [eligibility, buy, target]
                  description: "Defaults to eligibility, which is the only kind that gates the discount. buy and target describe the two sides of a Buy X Get Y offer and are read ONLY when the application method carries apply_to_quantity; on any other discount they are inert. Not settable on a patch: moving a rule out of eligibility removes the gate."
                attribute:
                  type: string
                  enum: [subtotal, currency_code, items.product.id, items.product.collection_id, customer.groups.id]
                  description: "Required on create. The cart fact this rule tests, from the closed set the checkout evaluator understands. subtotal is MINOR UNITS of the cart's own currency and carries no currency label, so \"5000\" is 50.00 in a two-decimal currency and \"50\" is fifty cents. buy and target rules are matched one cart line at a time and can only use items.product.id and items.product.collection_id."
                operator:
                  type: string
                  enum: [eq, ne, in, nin, gt, gte, lt, lte]
                  description: "Required on create, and restricted per attribute to what the evaluator can compute: the id and group attributes accept only in and nin, currency_code accepts only the equality operators, and gt/gte/lt/lte compare against exactly one value. An unevaluable pair is refused rather than stored."
                values:
                  type: array
                  items: { type: string }
                  description: "Required on create, never empty, at most 200 entries of at most 128 characters. Strings for every attribute including subtotal, because the column is text and the evaluator parses it. On a patch the whole set is REPLACED; there is no add or remove."
                description:
                  type: string
                  description: "Merchant notes. Nothing reads it."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attribute:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      discount_id:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      operator:
                        type: string
                      updated_at:
                        type: string
                      values:
                        type: array
                        items:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/discounts/{discountId}/rules/{ruleId}:
    get:
      summary: Retrieve a discount rule
      operationId: get_discounts_by_discountId_rules_by_ruleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: ruleId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attribute:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      discount_id:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      operator:
                        type: string
                      updated_at:
                        type: string
                      values:
                        type: array
                        items:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a discount rule
      operationId: patch_discounts_by_discountId_rules_by_ruleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: discountId
          in: path
          required: true
          schema: { type: string }
        - name: ruleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Every value is an absolute target, so replaying converges, and
                values REPLACES the whole set rather than adding to it. The
                attribute, operator and value set are checked as MERGED with what
                is already stored, so a patch naming only the operator is refused
                when the stored attribute cannot honour it. kind is not settable:
                moving an eligibility rule to buy or target does not narrow the
                discount, it removes the gate and makes the discount unconditional
                again. Eligibility is evaluated at checkout time rather than
                snapshotted onto the cart, so narrowing a rule changes what a
                shopper already in checkout pays, with no notification.
              properties:
                attribute:
                  type: string
                  enum: [subtotal, currency_code, items.product.id, items.product.collection_id, customer.groups.id]
                  description: "Required on create. The cart fact this rule tests, from the closed set the checkout evaluator understands. subtotal is MINOR UNITS of the cart's own currency and carries no currency label, so \"5000\" is 50.00 in a two-decimal currency and \"50\" is fifty cents. buy and target rules are matched one cart line at a time and can only use items.product.id and items.product.collection_id."
                operator:
                  type: string
                  enum: [eq, ne, in, nin, gt, gte, lt, lte]
                  description: "Required on create, and restricted per attribute to what the evaluator can compute: the id and group attributes accept only in and nin, currency_code accepts only the equality operators, and gt/gte/lt/lte compare against exactly one value. An unevaluable pair is refused rather than stored."
                values:
                  type: array
                  items: { type: string }
                  description: "Required on create, never empty, at most 200 entries of at most 128 characters. Strings for every attribute including subtotal, because the column is text and the evaluator parses it. On a patch the whole set is REPLACED; there is no add or remove."
                description:
                  type: [string, "null"]
                  description: "Merchant notes. Nothing reads it."
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attribute:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      discount_id:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      operator:
                        type: string
                      updated_at:
                        type: string
                      values:
                        type: array
                        items:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/draft-orders:
    get:
      summary: List draft orders
      operationId: get_draft_orders
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_draft_orders
      x-required-scopes: [read_draft_orders]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Drafts in one state. An unrecognised value is a 400 naming the accepted set.
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            One customer's drafts. NOT vocabulary-checked: an unknown customer id returning an
            empty page is a true answer rather than a typo class.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Drafts touched at or after this RFC3339 instant. A DRAFT ORDER IS A CART A MERCHANT
            IS BUILDING BY HAND, so its lines and totals change repeatedly between creation and
            completion, and created_at marks the one moment about it that never matters again.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        completed_order_id:
                          type: [string, "null"]
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: [string, "null"]
                        email:
                          type: [string, "null"]
                        id:
                          type: string
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              product_title:
                                type: string
                              quantity:
                                type: integer
                              unit_price_minor:
                                type: integer
                              variant_id:
                                type: string
                              variant_title:
                                type: string
                        status:
                          type: string
                        subtotal_minor:
                          type: integer
                        total_minor:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Open a draft order
      operationId: post_draft_orders
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders
      x-required-scopes: [write_draft_orders]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currency_code]
              additionalProperties: false
              description: >-
                Creates an EMPTY quote. Lines are added one at a time by POST
                /api/v1/draft-orders/{draftOrderId}/items, so each is priced from the
                catalogue rather than from a body, and there is deliberately no items
                array here.
                id, status, subtotal_minor, total_minor, completed_order_id, items,
                store_id and the timestamps are refused BY NAME rather than ignored, so a
                client that sent status "completed" is told instead of reading a 201 as a
                converted quote.
              properties:
                currency_code:
                  type: string
                  description: >-
                    REQUIRED, and there is no store default to fall back on. The two money
                    fields are stored in it and it cannot be changed afterwards, so a
                    guessed currency would permanently mislabel an outstanding offer. Any
                    3-letter code, matched case-insensitively and served back lowercase.
                email:
                  type: string
                  description: >-
                    The buyer's address. Optional here and settable later. A draft cannot
                    be completed without one, so its presence is the readiness signal.
                customer_id:
                  type: string
                  description: >-
                    An opaque reference into /api/v1/customers. No foreign key validates
                    it, so an unknown id is accepted exactly as the dashboard accepts one.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          {}
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/draft-orders/{draftOrderId}:
    delete:
      summary: Discard a draft order
      description: >-
        Discards the quote and every line on it. There is no restore, and recreating is
        not restoring: the new draft has a new id, so a link already sent to the buyer
        still 404s.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_draft_orders alone, so ask the merchant for both. Clearing stale quotes is
        a legitimate integration and a destructive one, and the qualifier is what keeps
        the two apart.
        IDEMPOTENCY HERE IS PER KEY, NOT PER RESOURCE. Replaying the same
        Idempotency-Key returns the stored 204 without touching anything. A FRESH key
        against a draft that is already gone is 404, because "your retry already landed"
        and "there is no such draft" are different facts, and answering 204 to a mistyped
        id would read as a clean-up that never happened while the real quote stayed live.
      operationId: delete_draft_orders_by_draftOrderId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders:delete
      x-required-scopes: [write_draft_orders, write_draft_orders:delete]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_draft_orders and then on write_draft_orders:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_draft_orders, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a draft order
      operationId: get_draft_orders_by_draftOrderId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_draft_orders
      x-required-scopes: [read_draft_orders]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                            variant_title:
                              type: string
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a draft order's contact details
      operationId: patch_draft_orders_by_draftOrderId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders
      x-required-scopes: [write_draft_orders]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                CONTACT ONLY, and send at least one of the two. Everything else on a draft
                is computed (the money fields), stamped (status, completed_order_id, the
                timestamps) or fixed at create (currency_code), and each is refused by
                name with the reason.
                THREE STATES PER FIELD. An omitted key leaves the column alone, an explicit
                null CLEARS it, and a string sets it. An empty string is a 400: null means
                "not captured" on the way out, and a body that could send "" as a clear
                would make the two the same request.
                Open drafts only. A completed draft answers 409 draft_not_open, because
                rewriting the buyer on the quote behind a placed order would leave the two
                disagreeing with nothing recording that they ever did.
              properties:
                email:
                  type: [string, "null"]
                  description: The buyer's address. Null clears it.
                customer_id:
                  type: [string, "null"]
                  description: >-
                    The customer this quote is for. Null DETACHES the customer, which is
                    the only way to undo attaching the wrong one.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                            variant_title:
                              type: string
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/draft-orders/{draftOrderId}/items:
    post:
      summary: Add a line to a draft order
      operationId: post_draft_orders_by_draftOrderId_items
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders
      x-required-scopes: [write_draft_orders]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, quantity]
              additionalProperties: false
              description: >-
                TWO FIELDS, and the shortness is the point. The line's price and both
                titles are read from the catalogue inside the same transaction, so this
                call chooses WHICH variant and HOW MANY and never what it costs.
                unit_price_minor, product_title and variant_title are refused BY NAME.
                ADDING A VARIANT THE DRAFT ALREADY CARRIES ADDS TO ITS QUANTITY rather
                than replacing it, and the existing line keeps the price it was quoted at.
                Use the PATCH below to set an absolute quantity.
                The response is the DRAFT with its totals recomputed, not the line: a line
                is not separately addressable, so a 201 would promise a Location that does
                not exist.
              properties:
                variant_id:
                  type: string
                  description: >-
                    A variant in this store. One that is not is 400 invalid_body naming
                    variant_id, rather than a 404, because the draft in the path does
                    exist and a 404 would send you to debug the wrong id.
                quantity:
                  type: integer
                  minimum: 1
                  description: >-
                    REQUIRED and never defaulted to 1: a defaulted quantity turns a field
                    lost in a marshal into a wrong quote that looks right.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                            variant_title:
                              type: string
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/draft-orders/{draftOrderId}/items/{itemId}:
    delete:
      summary: Remove a line from a draft order
      description: >-
        Removes one line and returns the DRAFT with subtotal_minor and total_minor
        recomputed, so you do not have to re-read the quote to learn its new price.
        NEEDS ONLY write_draft_orders, unlike the draft delete above, and the asymmetry
        is deliberate. Removing a line is composing the document you are already
        authoring: nothing else references a line, and adding the variant back undoes it.
        Open drafts only; a completed draft is 409 draft_not_open.
      operationId: delete_draft_orders_by_draftOrderId_items_by_itemId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders
      x-required-scopes: [write_draft_orders]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
        - name: itemId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          {}
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Set a draft order line's quantity
      operationId: patch_draft_orders_by_draftOrderId_items_by_itemId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_draft_orders
      x-required-scopes: [write_draft_orders]
      parameters:
        - name: draftOrderId
          in: path
          required: true
          schema: { type: string }
        - name: itemId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quantity]
              additionalProperties: false
              description: >-
                Sets an ABSOLUTE quantity, which is the difference from the add above: a
                reconciler that knows the buyer wants four sends four rather than a delta
                it would get wrong after a partial failure.
                Quantity is the only thing a line edit moves. variant_id is refused by
                name: the variant, the titles and the price were snapshotted when the line
                was added so an outstanding quote does not shift when the catalogue does.
                Remove the line and add the other variant instead.
              properties:
                quantity:
                  type: integer
                  minimum: 1
                  description: The line's new quantity, replacing what was there.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      completed_order_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: [string, "null"]
                      email:
                        type: [string, "null"]
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                            variant_title:
                              type: string
                      status:
                        type: string
                      subtotal_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_draft_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-delivery-audit:
    get:
      summary: List email delivery audit
      description: >-
        Every recorded change to this store's provider connections, sending domains and
        suppression list, newest first. APPEND-ONLY: this platform holds no permission to
        update or delete a row, so a mistake can only be contradicted by a later entry.
        actor IS THE API KEY ID for a change made through this API and a dashboard user id
        for one made by a person, which is how you tell an integration's writes from your
        merchant's own. It is opaque: compare it, do not parse it.
        A WRITE THAT CHANGED NOTHING RECORDS NOTHING. Re-adding an address that is already
        suppressed leaves the row alone and appends no entry, so a redelivering webhook
        does not fill the trail with lines about a change it did not make.
        entity_id names the changed row in its own terms: the provider name, the domain,
        or the suppressed ADDRESS, which is why this feed sits behind the same grant as
        the suppression list.
        This is NOT /api/v1/events, which is the platform-wide audit over a different
        table. These are the three email-delivery entities and nothing else.
      operationId: get_email_delivery_audit
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: entity
          in: query
          required: false
          schema: { type: string, enum: [provider_connection, domain_registration, suppression] }
          description: >-
            Restrict the page to changes against one kind of thing. A value outside the
            enum is a 400 rather than an empty page, which would read as "nothing
            changed".
        - name: action
          in: query
          required: false
          schema: { type: string }
          description: >-
            Exact match on the recorded verb (add, upsert, register, set_status, delete,
            remove). It is NOT validated against a closed set, because the verbs are
            written by whichever part of the platform records the change and the list
            grows; an unknown value returns an empty page rather than a 400. Treat an
            action you do not recognise as "something changed" rather than failing.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        action:
                          type: string
                        actor:
                          type: [string, "null"]
                        created_at:
                          type: string
                        entity:
                          type: string
                        entity_id:
                          type: [string, "null"]
                        id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-delivery-failures:
    get:
      summary: List email delivery failures
      description: >-
        Messages this store could not deliver, with the retry schedule each is on. status
        dead is TERMINAL: the backoff schedule is exhausted and nothing will try that
        message again.
        THE MESSAGE ITSELF IS NOT PUBLISHED. The stored payload is written by whichever
        part of the platform enqueued it and has no fixed shape, so describing it here
        would publish a contract that changes underneath you. What is published is the
        deliverability signal: how many attempts a message has burned, when the next one
        is due, and the provider's own error text.
        AN EMPTY PAGE IS THE HEALTHY ANSWER and is what most stores read every day.
      operationId: get_email_delivery_failures
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, retrying, dead] }
          description: >-
            Filter by where the message is in its retry schedule. dead is terminal.
            A value outside the enum is a 400 rather than an empty page, which would
            read as "nothing failed".
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attempts:
                          type: integer
                        created_at:
                          type: string
                        id:
                          type: string
                        last_error:
                          type: [string, "null"]
                        next_retry_at:
                          type: [string, "null"]
                        status:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-domains:
    get:
      summary: List email domains
      description: >-
        The sending domains this store has registered with its provider, and how far each
        has got through verification.
        dkim_record is the DNS record the provider issued, and null until it has issued
        one. Publishing it in the merchant's DNS is what finishes verification, and this
        API cannot be told what that record is: see PUT /api/v1/email-domains/{domain}.
        NOTHING ON THIS PLATFORM GATES A SEND ON status. It is the merchant's record of
        what their provider says about their DNS, not a permission this API enforces.
      operationId: get_email_domains
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, verified, failed] }
          description: >-
            Filter by verification state. A value outside the enum is a 400 rather than
            an empty page, so polling on a misspelled "verifed" cannot read as "the
            verification never landed".
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        dkim_record:
                          type: [string, "null"]
                        domain:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Register a sending domain with a connected provider
      description: >-
        Registers a sending domain and asks the named provider to verify it. Registering
        a domain that is already registered RE-ASKS the provider and refreshes what is
        stored, which is how a verification that finished later becomes visible, so a
        repeat is the retry rather than a mistake.
        provider must name a connection this store already has AND has enabled. A store
        with no enabled connection is refused rather than handed a registration no
        provider has been told about.
        THE RESPONSE CARRIES THE DKIM RECORD the provider issued, which is the point of
        the call: publish it in the domain's DNS. It cannot be sent IN.
        Expect status pending. Verification finishes in DNS, not here.
      operationId: post_email_domains
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_email_delivery
      x-required-scopes: [write_email_delivery]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider, domain]
              additionalProperties: false
              description: >-
                AN UPSERT. Registering a domain that is already registered re-asks the
                provider and refreshes what is stored, so a repeat is the retry rather than
                a conflict.
                dkim_record and status are REFUSED BY NAME rather than ignored. The record
                is the provider's own instruction, and one accepted from a caller could be
                an attacker's signing key published in the merchant's DNS on this API's
                say-so; the status is the provider's verdict, recorded through PUT
                /api/v1/email-domains/{domain} once you have it.
              properties:
                provider:
                  type: string
                  description: >-
                    Which connected relay is asked to verify. It must name a connection
                    this store has AND has enabled, or the call is refused 404 rather than
                    storing a registration no provider knows about. List them at
                    /api/v1/email-providers.
                domain:
                  type: string
                  description: The sending domain, for example mail.example.com.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      dkim_record:
                        type: [string, "null"]
                      domain:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-domains/{domain}:
    get:
      summary: Retrieve an email domain
      description: >-
        One registration, addressed by DOMAIN NAME. This is the route to poll after a
        registration: verification is a DNS round trip the merchant completes, so a fresh
        registration reads pending until the provider or your own integration says
        otherwise.
        updated_at moves when the status does, so "still pending since Tuesday" is
        distinguishable from "re-checked a minute ago and still pending".
      operationId: get_email_domains_by_domain
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: domain
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      dkim_record:
                        type: [string, "null"]
                      domain:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Record a verification outcome for a sending domain
      description: >-
        Records a verification outcome for a domain this store has already registered.
        Your integration is usually the only party that can see the provider's verdict,
        so this is how a registration made through the API ever leaves pending.
        ONE FIELD. status is the whole of what this route writes.
        IT CANNOT WRITE dkim_record AND NEVER WILL. A record accepted from a caller could
        be an attacker's signing key, published in the merchant's own DNS on this API's
        say-so and then usable to sign mail as their domain. Sending the key is a 400 that
        names it, rather than a silent drop that would leave you believing you set it.
        A domain this store has not registered is a 404 rather than a create: the
        registration is what asked a provider to verify in the first place.
      operationId: put_email_domains_by_domain
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_email_delivery
      x-required-scopes: [write_email_delivery]
      parameters:
        - name: domain
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              additionalProperties: false
              description: >-
                ONE FIELD, and the narrowness is the contract. dkim_record is refused by
                name on this route: the admin surface writes both columns together, and a
                key able to set the record could put an attacker's signing key in front of
                the merchant, who would publish it in real DNS.
                No precondition is required. The route writes a single field, so the only
                thing a race can lose is a status two callers disagree about, which the
                next read shows and the next write corrects.
              properties:
                status:
                  type: string
                  enum: [pending, verified, failed]
                  description: >-
                    What the provider says about this domain now. Nothing on this platform
                    gates a send on it: it is the merchant's record of their own DNS.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      dkim_record:
                        type: [string, "null"]
                      domain:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-providers:
    get:
      summary: List email providers
      description: >-
        Which outbound relays this store has connected, and whether each is switched on.
        THIS IS THE INFRASTRUCTURE UNDER THE MAIL, not the mail. What the store SENT,
        the per-event copy and the send log naming each recipient, is the notifications
        family and a different grant.
        NO CREDENTIAL IS PUBLISHED AND NONE CAN BE. The stored secret is sealed and is
        unsealed only for one send; has_secret reports whether one exists and is the
        whole of what this API will say about it. The provider's configuration document
        is not published either: it is free-form text this platform never inspects, so
        it is where a mispasted credential would sit.
        THERE IS NO WRITE ON THIS RESOURCE and there will not be. A key able to replace a
        connection would point every message the store sends, password resets included,
        at a relay of its choosing, with nothing on the merchant's side looking different.
      operationId: get_email_providers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        has_secret:
                          type: boolean
                        is_enabled:
                          type: boolean
                        provider:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-providers/{provider}:
    get:
      summary: Retrieve an email provider
      description: >-
        One connection, addressed by PROVIDER NAME rather than by an id. A relay this
        store has not connected is a 404, which is a different state from a connection
        that exists with is_enabled false: the second is something the merchant did.
      operationId: get_email_providers_by_provider
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      has_secret:
                        type: boolean
                      is_enabled:
                        type: boolean
                      provider:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/email-suppressions:
    get:
      summary: List email suppressions
      description: >-
        Every address this store must never mail again, whatever the message. It is
        published so you can honour the same suppressions in your own sending. It is not
        a mailing list, and treating it as a source of contactable people inverts its
        entire meaning.
        THERE IS NO GET BY ADDRESS because ?email= answers the same question better. "Is
        this address suppressed" returns an empty page when it is not, which is the
        correct answer; a 404 would be an error status for a perfectly normal outcome and
        every client would have to treat it as success.
        ADDRESSES ARE STORED AND MATCHED VERBATIM. This platform's send gate compares the
        stored address to the recipient exactly, so filter on the address as you have it
        rather than normalising first: a lowercased query can report an address absent
        that the gate will not match either way.
      operationId: get_email_suppressions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_email_delivery
      x-required-scopes: [read_email_delivery]
      parameters:
        - name: email
          in: query
          required: false
          schema: { type: string, format: email }
          description: >-
            EXACT match on one address, which is how you ask "is this address
            suppressed" before a send. An address that is not suppressed returns an
            empty page, which is the answer rather than an error. It is never a prefix
            or a pattern: that would turn a suppression list into a directory to
            enumerate by guessing.
            Send the address as you hold it. Matching is exact and so is this
            platform's own send gate, so normalising first can report an address absent
            that the gate will not match either.
        - name: reason
          in: query
          required: false
          schema: { type: string, enum: [bounce, complaint, manual, unsubscribe] }
          description: >-
            Filter by why the address was suppressed. A value outside the enum is a 400
            rather than an empty page: "nobody was suppressed for that reason" and
            "there is no such reason" are different facts, and a client told the former
            concludes its complaint feed arrived when it did not.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        reason:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Suppress an address from every message this store sends
      description: >-
        Suppresses an address, so no message this store sends reaches it again. This is
        how an ESP forwards its own unsubscribes, bounces and complaints into the
        platform's list, which is the reason this family is published at all.
        RE-ADDING AN ALREADY-SUPPRESSED ADDRESS IS NOT AN ERROR and does not move it. You
        get back the ORIGINAL reason and timestamp, so a complaint recorded last month is
        not restamped as today's manual entry, and a webhook redelivery cannot turn into
        a retry storm.
        reason IS REQUIRED AND IS NOT DEFAULTED. It is a compliance record rather than a
        label: a spam complaint filed as a manual removal disappears from the store's
        evidence that it stopped mailing when asked.
        THERE IS NO WAY TO REMOVE A SUPPRESSION THROUGH THIS API, deliberately. Removing
        one resumes mail to a person who bounced or complained, so no scope buys it; the
        merchant can do it from their own dashboard.
      operationId: post_email_suppressions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_email_delivery
      x-required-scopes: [write_email_delivery]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, reason]
              additionalProperties: false
              description: >-
                Both keys are required and NEITHER IS DEFAULTED. A missing reason would
                otherwise be filed as a manual removal, and a spam complaint recorded as a
                manual removal disappears from the store's evidence that it stopped mailing
                when asked.
                201 EVEN ON A REPEAT, and the body is the suppression NOW IN FORCE, which
                on a repeat is the row already on file rather than the one you described.
                Compare the reason and created_at you get back against what you sent if you
                need to know which happened. Branching the status on that would leak whether
                an address is suppressed to a key holding only the write grant.
              properties:
                email:
                  type: string
                  format: email
                  description: >-
                    The address to suppress. It is stored VERBATIM, because this platform's
                    send gate compares the stored address to the recipient exactly. Send it
                    as your own system holds it rather than normalising first.
                reason:
                  type: string
                  enum: [bounce, complaint, manual, unsubscribe]
                  description: >-
                    Why the address must not be mailed. It is a compliance record rather
                    than a label, and a value outside the enum is a 400 rather than being
                    coerced.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      reason:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_email_delivery scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/events:
    get:
      summary: List events
      description: >-
        The store's audit log: who changed what, newest first. It is a RECORD OF WRITES
        for compliance and support, not a delivery mechanism, and reading it is not a
        substitute for subscribing to webhooks.
        ITS NAMES ARE NOT WEBHOOK TOPICS. ?action= takes the dotted spelling this log
        uses (promotion.created, store.reindexed) and those values are REFUSED by POST
        /api/v1/webhook-endpoints, which takes the slashed WebhookTopic spelling
        (products/created, orders/paid) and refuses everything else. The two
        vocabularies are disjoint, they are not translations of each other, and neither
        route accepts the other's values. Read the WebhookTopic schema for the
        subscribable set; there is no route that lists this log's actions, because it
        records whatever the writes of the day produced rather than a closed set.
        resource_type and resource_id are the join back into the resource that changed,
        and both are null on a row about the store itself.
      operationId: get_events
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_events
      x-required-scopes: [read_events]
      parameters:
        - name: action
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this action. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: actor_type
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this actor_type. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: created_at_max
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this created_at_max. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: created_at_min
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this created_at_min. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: resource_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this resource_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: resource_type
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this resource_type. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        action:
                          type: string
                        actor_type:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        resource_id:
                          nullable: true
                        resource_type:
                          nullable: true
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_events scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/events/{eventId}:
    get:
      summary: Retrieve an event
      operationId: get_events_by_eventId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_events
      x-required-scopes: [read_events]
      parameters:
        - name: eventId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                      actor_type:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_events scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/exchanges:
    get:
      summary: List exchanges
      operationId: get_exchanges
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this order_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        canceled_at:
                          nullable: true
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        difference_total_minor:
                          type: integer
                        id:
                          type: string
                        inbound:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              order_line_item_id:
                                type: string
                              quantity:
                                type: integer
                        order_id:
                          type: string
                        outbound:
                          type: array
                          items:
                            type: object
                            properties:
                              currency_code:
                                type: string
                              id:
                                type: string
                              quantity:
                                type: integer
                              unit_price_minor:
                                type: integer
                              variant_id:
                                type: string
                        received_at:
                          nullable: true
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/exchanges/{exchangeId}:
    get:
      summary: Retrieve an exchange
      operationId: get_exchanges_by_exchangeId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: exchangeId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      difference_total_minor:
                        type: integer
                      id:
                        type: string
                      inbound:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      outbound:
                        type: array
                        items:
                          type: object
                          properties:
                            currency_code:
                              type: string
                            id:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                      received_at:
                        nullable: true
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/exchanges/{exchangeId}/approve:
    post:
      summary: Approve a requested exchange
      operationId: post_exchanges_by_exchangeId_approve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: exchangeId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Moves an exchange from
                requested to approved and does nothing else: no stock is reserved, no stock is
                restocked, and no money moves. The price difference settles on SHIP, which this
                API does not publish, so an approval promises money later and moves none now.
                LEGAL ONLY FROM requested. An exchange that is already approved, received,
                shipped or cancelled answers 409 invalid_transition, so this is not idempotent
                by shape: a replayed Idempotency-Key answers the stored response, and a FRESH
                key on an already-approved exchange answers 409 rather than 200. Read the
                exchange back to see where it is.
                THE BODY IS READ ONLY TO REFUSE IT. reason, note and cancel_reason are refused
                BY NAME because the exchange row has no column for prose, so text sent here
                would be accepted, dropped, and unreadable through any route afterwards.
                restock and location_id are refused because choosing where returned goods land
                is write_inventory's authority, and neither published transition moves stock.
                refund, refund_to, amount, difference_total and difference_total_minor are
                refused because no money moves here at all, and the figure is a snapshot taken
                when the exchange was opened that no transition re-prices. status is refused
                because the route in the url is the transition. The provenance keys (id,
                store_id, order_id, created_by, created_at, updated_at) are refused for the
                usual reason: the server owns them.
                The response is the WHOLE exchange in the shape
                GET /api/v1/exchanges/{exchangeId} serves, so treat it as a fresh read rather
                than following it with a GET.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      difference_total_minor:
                        type: integer
                      id:
                        type: string
                      inbound:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      outbound:
                        type: array
                        items:
                          type: object
                          properties:
                            currency_code:
                              type: string
                            id:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                      received_at:
                        nullable: true
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/exchanges/{exchangeId}/cancel:
    post:
      summary: Cancel an exchange
      operationId: post_exchanges_by_exchangeId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: exchangeId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. THIS FAMILY'S DELETE: there
                is no DELETE route and there will not be one, because cancelling leaves the row,
                both item sides and the stamps exactly where a chargeback dispute six months
                later needs to find them. canceled_at is stamped the first time the exchange
                enters cancelled and is never rewritten.
                LEGAL FROM requested, approved AND received, which is wider than approve's
                single source state, because an exchange can be called off at any point before
                the replacement ships. From shipped, or from cancelled again, it answers 409
                invalid_transition, so a second cancel does not answer 200.
                NO MONEY MOVES, whatever difference_total_minor says. That figure is a snapshot
                taken when the exchange was opened and it settles on SHIP, which this API does
                not publish, so nothing is charged to the customer and nothing is refunded here.
                CANCELLING AFTER RECEIPT DOES NOT UNDO THE RESTOCK. Receiving an exchange puts
                the inbound goods back into stock; cancelling afterwards flips the status and
                leaves that stock exactly where it is. Move it again through the inventory api
                if it is not staying on the shelf.
                THE BODY IS READ ONLY TO REFUSE IT, against the same list the approve route
                uses. cancel_reason is the key an integrator reaches for first and it is refused
                BY NAME, because the exchange row has no column for prose: a reason sent here
                would be dropped while the 200 read as though it had been recorded. refund,
                refund_to and amount are refused because cancelling moves no money, restock and
                location_id because stock is write_inventory's authority, status because the
                route in the url is the transition, and the provenance keys (id, store_id,
                order_id, created_by, canceled_at, created_at, updated_at) because the server
                owns them.
                The response is the WHOLE exchange in the shape
                GET /api/v1/exchanges/{exchangeId} serves.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      difference_total_minor:
                        type: integer
                      id:
                        type: string
                      inbound:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      outbound:
                        type: array
                        items:
                          type: object
                          properties:
                            currency_code:
                              type: string
                            id:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                      received_at:
                        nullable: true
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/exchanges/{exchangeId}/receive:
    post:
      summary: Record that an exchange's returned goods arrived
      operationId: post_exchanges_by_exchangeId_receive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: exchangeId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. THIS ONE MOVES STOCK, which
                is the fact that separates it from the other two transitions this API
                publishes: every inbound line is RESTOCKED into its own order line's location,
                the place the goods shipped from, in the same transaction as the status flip.
                An inbound line whose order line no longer records a location is received with
                no restock for that line.
                THE CALLER CHOOSES NOTHING ABOUT THE MOVE. Not the destination, not the
                quantity (fixed when the exchange was opened), not whether it happens at all,
                which is why restock and location_id are still refused BY NAME: recording that
                goods came back is this grant's business, and putting stock into a named
                location is write_inventory's.
                IT IS ALL OR NOTHING. The restock and the status change share one transaction,
                so a failed restock rolls the receive back and the exchange stays approved.
                There is no state where the API says received and the stock never moved.
                A RESTOCK CAN REACH SHOPPERS. A variant that was sold out and is buyable again
                because of this receipt fires the store's back-in-stock notifications after the
                commit, so this call can send email to people who are not the merchant.
                NO MONEY MOVES, whatever difference_total_minor says. That figure is a snapshot
                taken when the exchange was opened, in the MINOR UNITS of the order's currency,
                and it settles on SHIP, which this API does not publish. Nothing is charged and
                nothing is refunded here.
                LEGAL ONLY FROM approved, and that guard is what makes the stock movement safe
                to retry. Three retry shapes, all different: replaying the SAME
                Idempotency-Key returns the stored 200 and the handler never runs; a FRESH key
                after a success answers 409 invalid_transition under the row lock and CANNOT
                restock a second time; a fresh key after a 5xx executes for the first time,
                because the transaction rolled back and the exchange is still approved. A
                requested, shipped or cancelled exchange is 409 as well.
                409 order_line_missing IS THE ONE REFUSAL THAT IS NOT A TRANSITION ERROR: an
                inbound item points at an order line that no longer exists, so there is nowhere
                to return those goods to. Nothing was received and no stock moved.
                CANCELLING AFTERWARDS DOES NOT UNDO THE RESTOCK. Cancel is legal from received
                and flips the status while leaving that stock exactly where this call put it;
                move it again through the inventory api if it is not staying on the shelf.
                THE BODY IS READ ONLY TO REFUSE IT, against the same list the other two
                transitions use. received_at is refused BY NAME because the platform stamps it
                the first time goods are received and back-dating a receipt would put the
                inventory movement and its stamp on different days. refund, refund_to, amount,
                difference_total and difference_total_minor are refused because no money moves
                here, reason and note because the exchange row has no column for prose, status
                because the route in the url is the transition, and the provenance keys (id,
                store_id, order_id, created_by, canceled_at, created_at, updated_at) because
                the server owns them.
                The response is the WHOLE exchange in the shape
                GET /api/v1/exchanges/{exchangeId} serves.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      difference_total_minor:
                        type: integer
                      id:
                        type: string
                      inbound:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      outbound:
                        type: array
                        items:
                          type: object
                          properties:
                            currency_code:
                              type: string
                            id:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: string
                      received_at:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiment-audiences:
    get:
      summary: List experiment audiences
      description: >-
        The targeting rule sets an experiment gates on, newest first. They ride
        read_experiments rather than a scope of their own because a client that could read
        the test but not who it runs against would show a result it cannot explain.
        rules is AND across the dimensions that are set and any-of within each. An absent
        dimension is unconstrained, not empty: an audience with no rules at all matches
        every visitor.
      operationId: get_experiment_audiences
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        rules:
                          type: object
                          properties:
                            countries:
                              type: array
                              items:
                                type: string
                            customer_groups:
                              type: array
                              items:
                                type: string
                            devices:
                              type: array
                              items:
                                type: string
                            returning:
                              type: boolean
                            utm_sources:
                              type: array
                              items:
                                type: string
                        updated_at:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiment-audiences/{audienceId}:
    get:
      summary: Retrieve an experiment audience
      description: >-
        One audience, in the same shape the list serves. A 404 does NOT mean an experiment
        naming this id is broken: the reference is soft, and assignment treats a missing
        audience as "all visitors".
      operationId: get_experiment_audiences_by_audienceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: audienceId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      rules:
                        type: object
                        properties:
                          countries:
                            type: array
                            items:
                              type: string
                          devices:
                            type: array
                            items:
                              type: string
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiment-cost-model:
    get:
      summary: Retrieve experiment cost model
      description: >-
        The merchant's real contracted payment-processing and carrier rates, which is
        PER-ORDER MARGIN and the most commercially sensitive figure this API serves. It is
        why read_experiments is a grant of its own rather than part of read_products: a key
        granted the catalogue has not been granted the markup on it.
        Read-only, permanently. Rewriting a rate silently restates every profit figure in
        every result snapshot the merchant reads back, with nothing erroring anywhere, so no
        write_experiments scope exists to be minted.
        payment_fee_fixed_minor is MINOR UNITS AND THE TABLE CARRIES NO CURRENCY COLUMN: the
        platform applies the same flat figure to an order in any currency, so the store's
        default currency is the only defensible reading.
        A store that has never written a cost model answers 200 with every rate at 0 and
        updated_at null, which is the platform's own fail-safe: profit is then exactly the
        pre-cost figure rather than a wrong cost subtracted.
      operationId: get_experiment_cost_model
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      payment_fee_bps:
                        type: integer
                      payment_fee_fixed_minor:
                        type: integer
                      shipping_cost_bps:
                        type: integer
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiment-settings:
    get:
      summary: Retrieve experiment settings
      description: >-
        The programme-wide holdback: the share of visitors held out of EVERY experiment, in
        basis points of 10000. It is the control the aggregate lift of the whole
        optimization effort is measured against, and a client reading a per-experiment
        result should know it exists, since the held-out slice is in no arm's exposures.
        A store that has never written the row answers 200 with holdback_bps 0 and
        updated_at null rather than 404. "Nobody is held out" is a real state of the
        programme and is what the engine itself does with a missing row.
      operationId: get_experiment_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      holdback_bps:
                        type: integer
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiment-usage:
    get:
      summary: Retrieve experiment usage
      description: >-
        How many DISTINCT visitors were first exposed to any experiment in the store within
        a window. It is the number that says whether the programme is reaching enough
        traffic for its verdicts to mean anything.
        NOT A BILLING FIGURE. Store visits are decoupled from pricing on every plan, so this
        caps nothing and charges nothing.
        No visitor id crosses the wire, here or anywhere on this family: the rows behind the
        count are keyed by a shopper's device identity and only the count is published.
        The window is half-open and both ends are echoed, since the end is the server clock
        and two calls a minute apart would otherwise differ with nothing explaining why.
      operationId: get_experiment_usage
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: since
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Start of the window, RFC3339. Defaults to a trailing thirty days, and a
            lookback longer than two years is clamped to two. A value that is not RFC3339
            is a 400 rather than a fallback to the default, so a client with a malformed
            timestamp never reads a thirty-day figure as its own window. The response
            echoes both ends of the window actually used; the end is the server clock and
            cannot be set.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      optimized_visitors:
                        type: integer
                      since:
                        type: string
                      until:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiments:
    get:
      summary: List experiments
      description: >-
        The store's A/B tests, newest first, each with its ARMS. The variants are on the
        list as well as the detail read, because an experiment without them is a name: the
        arms are what say what the test actually changes.
        Draft and archived tests are included. Use ?status=running for the live ones.
        created_by is NOT published. It is the internal user id of the staff member who
        filed the test, and read_experiments is a grant over the optimize programme's data
        rather than over the merchant's staff directory.
        A price experiment's variant config sets the amount a shopper is charged, either as
        an absolute per-currency price or as a signed basis-points delta applied to every
        variant of a product or a whole collection. It is readable here and writable
        nowhere on this API: no write_experiments scope exists to be minted.
      operationId: get_experiments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, running, paused, concluded, archived] }
          description: >-
            Only running experiments serve to shoppers. Omit it for every status: an
            archived test is what a historical result belongs to and a draft is what the
            merchant is about to launch, so an unfiltered list is the right default for a
            client syncing tests outward.
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [content, price, offer, shipping, checkout, combination] }
          description: >-
            The lever a variant config targets. price is the one to watch: those variant
            configs set the amount a shopper is charged.
        - name: mode
          in: query
          required: false
          schema: { type: string, enum: [test, personalization] }
          description: >-
            test is a randomised A/B split measured against a control. personalization is
            an always-on targeted experience exempt from the holdback and the ramp, so its
            result snapshots carry no meaningful lift and a client reading lift should
            filter to test.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Experiments touched at or after this RFC3339 instant. STATUS AND TRAFFIC SPLIT ARE
            THE RESOURCE here, and both move on a schedule nobody drives through this API: a
            ramp raises traffic_bps over time and a conclusion flips status on its own. If you
            are deciding how to split live traffic, that movement is the whole reason to read
            the list. Supplying it CHANGES THE ORDER of this endpoint: the default walk
            descends created_at, and this one climbs updated_at, which is the only ordering in
            which a keyset walk over a mutable column cannot skip a row. An experiment edited
            mid-walk moves forward past your position and is returned again, so the walk is AT
            LEAST ONCE. The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        audience_id:
                          type: [string, "null"]
                        created_at:
                          type: string
                        ended_at:
                          nullable: true
                        exclusion_group:
                          type: [string, "null"]
                        hypothesis:
                          type: string
                        id:
                          type: string
                        mode:
                          type: string
                        name:
                          type: string
                        primary_metric:
                          type: string
                        ramp_minutes:
                          type: integer
                        started_at:
                          type: [string, "null"]
                        status:
                          type: string
                        traffic_bps:
                          type: integer
                        type:
                          type: string
                        updated_at:
                          type: [string, "null"]
                        variants:
                          type: array
                          items:
                            type: object
                            properties:
                              allocation_bps:
                                type: integer
                              config:
                                type: object
                              created_at:
                                type: string
                              id:
                                type: string
                              is_control:
                                type: boolean
                              key:
                                type: string
                              name:
                                type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiments/{experimentId}:
    get:
      summary: Retrieve an experiment
      description: >-
        One experiment with its arms, in the same shape the list serves, so a row read from
        a page can be re-read on its own rather than by walking the list again. An
        experiment belonging to another store is a 404 here exactly as it is invisible in
        the list.
        audience_id is a SOFT reference: deleting an audience never breaks a running test,
        because assignment treats a missing one as "all visitors". Read a 404 from
        GET /api/v1/experiment-audiences/{audienceId} as "untargeted", not as a broken row.
      operationId: get_experiments_by_experimentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: experimentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      audience_id:
                        type: [string, "null"]
                      created_at:
                        type: string
                      ended_at:
                        nullable: true
                      exclusion_group:
                        type: [string, "null"]
                      hypothesis:
                        type: string
                      id:
                        type: string
                      mode:
                        type: string
                      name:
                        type: string
                      primary_metric:
                        type: string
                      ramp_minutes:
                        type: integer
                      started_at:
                        type: [string, "null"]
                      status:
                        type: string
                      traffic_bps:
                        type: integer
                      type:
                        type: string
                      updated_at:
                        type: [string, "null"]
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allocation_bps:
                              type: integer
                            config:
                              type: object
                            created_at:
                              type: string
                            id:
                              type: string
                            is_control:
                              type: boolean
                            key:
                              type: string
                            name:
                              type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/experiments/{experimentId}/results:
    get:
      summary: List an experiment's results
      description: >-
        The stored verdict: one row per arm per slice. IT SERVES THE LAST COMPUTED
        SNAPSHOT AND RECOMPUTES NOTHING, so computed_at is the whole measure of how current
        the answer is and the merchant's dashboard is what refreshes it. Recomputing would
        be a write, and this family holds no write grant.
        DECIDE ON always_valid_p_value, not on p_value. The second is only honest at a
        pre-declared sample size, and a client polling this endpoint is monitoring
        continuously by definition. All three statistics are null on the control arm, which
        carries no test result by construction; reading a null as zero calls a winner on the
        baseline.
        CHECK srm_ok FIRST. False means the arms did not receive the traffic they were
        allocated, which invalidates every other number in the row. It is computed within
        the slice, and per-slice SRM is noisy on thin cells, so treat a false on a country
        row as a reason to distrust that row rather than the test.
        revenue_minor, profit_minor, ppv_minor and aov_minor are MINOR UNITS WITH NO
        CURRENCY: the rollup does not group by currency, so on a multi-currency store they
        sum more than one. That is valid for COMPARING arms, which are randomly assigned and
        therefore carry the same currency mix, and it is not a money value. Do not render
        either as a price.
        An unknown experiment id is a 404 rather than an empty page, so a typo can never be
        read as "this test produced nothing". An experiment that has never been analysed
        answers an empty page, which is a real state.
      operationId: get_experiments_by_experimentId_results
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_experiments
      x-required-scopes: [read_experiments]
      parameters:
        - name: experimentId
          in: path
          required: true
          schema: { type: string }
        - name: segment
          in: query
          required: false
          schema: { type: string }
          description: >-
            One slice of the verdict. overall is the whole experiment, device:mobile,
            device:desktop and device:tablet are the device classes, and country:XX is an
            ISO alpha-2 country. Omit it for every row, in which case the overall rows sort
            first. Anything outside this vocabulary is a 400 rather than an empty page, so
            a typo is never read as "this slice has no data".
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        always_valid_p_value:
                          nullable: true
                        aov_minor:
                          type: integer
                        computed_at:
                          type: string
                        conv_rate:
                          type: number
                        experiment_id:
                          type: string
                        exposures:
                          type: integer
                        id:
                          type: string
                        orders:
                          type: integer
                        p_value:
                          nullable: true
                        ppv_minor:
                          type: integer
                        prob_beat_control:
                          nullable: true
                        profit_minor:
                          type: integer
                        revenue_minor:
                          type: integer
                        segment:
                          type: string
                        srm_ok:
                          type: boolean
                        variant_key:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_experiments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/faqs:
    get:
      summary: List faqs
      operationId: get_faqs
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_faqs
      x-required-scopes: [read_faqs]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        answer:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        is_published:
                          type: boolean
                        position:
                          type: integer
                        question:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_faqs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a store FAQ entry
      operationId: post_faqs
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_faqs
      x-required-scopes: [write_faqs]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question, answer]
              additionalProperties: false
              description: >-
                Appended to the end of the store's list. is_published defaults to
                false when omitted, the same draft-first posture the dashboard's
                own create form takes: a plain bool needs no explicit-empty
                distinction here the way a clearing PUT does, because false is
                already an unambiguous, ordinary answer.
              properties:
                question:
                  type: string
                  description: 1 to 300 characters after trimming. The accordion heading.
                answer:
                  type: string
                  description: 1 to 5000 characters after trimming. Plain text, never HTML.
                is_published:
                  type: boolean
                  description: >-
                    Whether the storefront serves this entry, and whether it is
                    marked up as FAQPage structured data. Defaults to false.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answer:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_published:
                        type: boolean
                      position:
                        type: integer
                      question:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_faqs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/faqs/reorder:
    post:
      summary: Reorder the store's FAQ list
      operationId: post_faqs_reorder
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_faqs
      x-required-scopes: [write_faqs]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ordered_ids]
              additionalProperties: false
              description: >-
                REPLACES THE WHOLE ORDER, not a moved id and a target index: send
                every one of this store's current entry ids exactly once, in the
                order they should appear. A duplicate, an id from another store, or
                a list missing an entry someone else just added is refused
                wholesale as invalid_reorder rather than applying a partial order
                nobody chose.
              properties:
                ordered_ids:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        answer:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        is_published:
                          type: boolean
                        position:
                          type: integer
                        question:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_faqs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/faqs/{id}:
    delete:
      summary: Remove a store FAQ entry
      operationId: delete_faqs_by_id
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_faqs:delete
      x-required-scopes: [write_faqs, write_faqs:delete]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_faqs and then on write_faqs:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_faqs, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Update a store FAQ entry
      operationId: put_faqs_by_id
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_faqs
      x-required-scopes: [write_faqs]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question, answer]
              additionalProperties: false
              description: >-
                Edits the text and publish state without moving the entry's
                position. An id from another store is 404, indistinguishable from
                an absent one, so an id cannot be used to probe another store's
                list.
              properties:
                question:
                  type: string
                  description: 1 to 300 characters after trimming.
                answer:
                  type: string
                  description: 1 to 5000 characters after trimming. Plain text, never HTML.
                is_published:
                  type: boolean
                  description: >-
                    Whether the storefront serves this entry. Defaults to false,
                    the same posture the create route takes.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answer:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_published:
                        type: boolean
                      position:
                        type: integer
                      question:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_faqs scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/fulfillments:
    get:
      summary: List fulfillments
      operationId: get_fulfillments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_fulfillment
      x-required-scopes: [read_fulfillment]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: The parcels raised against one order.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Parcels in one state. An unrecognised value is a 400 naming the accepted set. An
            unknown order_id is NOT an error, because "that order has no fulfillments" is
            frequently the truth.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Fulfillments touched at or after this RFC3339 instant. A FULFILMENT IS A PARCEL
            MOVING: its status and tracking number are written after the row exists, so on the
            default ordering you learn a parcel was created and never that it shipped, which is
            usually the thing you are integrating for.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        carrier:
                          nullable: true
                        created_at:
                          type: string
                        id:
                          type: string
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              order_line_item_id:
                                type: string
                              quantity:
                                type: integer
                        order_id:
                          type: string
                        shipped_at:
                          nullable: true
                        status:
                          type: string
                        tracking_number:
                          nullable: true
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_fulfillment scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/fulfillments/{fulfillmentId}:
    get:
      summary: Retrieve a fulfillment
      operationId: get_fulfillments_by_fulfillmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_fulfillment
      x-required-scopes: [read_fulfillment]
      parameters:
        - name: fulfillmentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      carrier:
                        nullable: true
                      created_at:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      shipped_at:
                        nullable: true
                      status:
                        type: string
                      tracking_number:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_fulfillment scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/fulfillments/{fulfillmentId}/ship:
    post:
      summary: Mark a fulfillment shipped and hand it to a carrier
      operationId: post_fulfillments_by_fulfillmentId_ship
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_fulfillment
      x-required-scopes: [write_fulfillment]
      parameters:
        - name: fulfillmentId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [carrier]
              additionalProperties: false
              description: >-
                MAILS THE SHOPPER, every time, with no suppression path. Shipping a
                fulfillment that is not pending is refused with 409, so a retry
                cannot send a second notification. There is no route to correct a
                carrier or tracking number afterwards: the underlying statement
                writes both unconditionally, so a partial correction would erase
                the field it did not name.
              properties:
                carrier:
                  type: string
                  description: >-
                    Required. Placed verbatim in the shopper's shipped
                    notification, so it is the carrier's name rather than a code.
                tracking_number:
                  type: [string, "null"]
                  description: >-
                    Null means exactly what omitting it means: shipped with no
                    tracking. The read publishes this field as null, so a client
                    round-tripping that shape can send it straight back. Spelled
                    tracking_number, not tracking, which is what the merchant API
                    calls it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      carrier:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      shipped_at:
                        type: string
                      status:
                        type: string
                      tracking_number:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_fulfillment scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/fx-settings:
    get:
      summary: Retrieve fx settings
      description: >-
        How this store ROUNDS a price it converted out of its own currency. A SINGLETON: one
        rule per store, so there is no page block and no id in the path.
        exact leaves the converted number alone, nearest_whole rounds to a whole major unit,
        and nearest_99 and nearest_95 round to a whole major unit and then sit just below it.
        On a ZERO-DECIMAL currency such as JPY there is no fraction to sit below, so both
        charm rules behave as nearest_whole; read this together with the currency's
        decimal_digits under read_currencies.
        A store that has never set a rule has no row, and this answers with the default
        (exact) and a NULL updated_at rather than a 404. Send that null back as
        expected_updated_at on your first write; it is how a first write names a version that
        does not exist yet.
        NO EXCHANGE RATE IS PUBLISHED HERE AND THERE IS NO CONVERSION ENDPOINT. Rates are
        platform-wide reference data refreshed from a third party for every store at once,
        not merchant data, so they are not served under a merchant's key and a convert call
        would disclose them a digit at a time. This resource is the merchant's presentation
        policy, and nothing else.
      operationId: get_fx_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_fx
      x-required-scopes: [read_fx]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rounding_rule:
                        type: string
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_fx scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set how this store rounds converted prices
      operationId: put_fx_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_fx
      x-required-scopes: [write_fx]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rounding_rule, expected_updated_at]
              additionalProperties: false
              description: >-
                Sets the rounding rule applied to every price this store converts out of its
                own currency. Both fields are required.
                IT MOVES WHAT SHOPPERS ARE SHOWN, IMMEDIATELY. The rule is applied at
                conversion time rather than stored on any amount, so this call changes every
                foreign price the storefront quotes on the very next request. Nothing is
                migrated and no row is rewritten, which also means there is no window in
                which to catch a mistake before shoppers see it.
                A store that has never set a rule has no row; this creates it.
                THERE IS NO DELETE, and no write_fx:delete grant exists. Removing the row
                would not turn rounding off, it would restore exact, which sending
                rounding_rule exact already does and does reversibly.
              properties:
                rounding_rule:
                  type: string
                  enum: [exact, nearest_99, nearest_95, nearest_whole]
                  description: >-
                    exact leaves the converted number alone. nearest_whole rounds to a whole
                    major unit. nearest_99 and nearest_95 round to a whole major unit and
                    then sit just below it, which is the charm-pricing shape. On a
                    ZERO-DECIMAL currency such as JPY there is no fraction to sit below, so
                    both charm rules behave as nearest_whole.
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. Required, because this rule is a singleton
                    written by this route AND by the merchant's dashboard: a write naming no
                    version silently reverts whatever moved under it and answers 200 to both
                    parties. A mismatch is 409 settings_modified. null asserts that this
                    store has no rounding settings yet, which is the only way a first write
                    can name a version that does not exist, and it is refused once a row does
                    exist.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rounding_rule:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_fx scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/geo-rules:
    get:
      summary: List geo rules
      description: >-
        The store's geolocation rules: which shoppers are routed where, and how.
        ONE LIST COVERS BOTH QUESTIONS a client asks. ?enabled=true is the set the
        storefront actually evaluates; ?enabled=false is what the merchant has staged
        and not switched on; omitting it returns both.
        PAGED BY CREATION TIME, NOT BY priority. priority is the evaluation order and
        it reads first in the dashboard, but it is an editable, non-unique integer, so
        a cursor over it would drop or repeat rules the moment a merchant
        re-prioritised one mid-walk. Sort on priority yourself once you hold the page.
      operationId: get_geo_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_geo
      x-required-scopes: [read_geo]
      parameters:
        - name: enabled
          in: query
          required: false
          schema: { type: boolean }
          description: >-
            true narrows the page to the rules the storefront evaluates, false to the
            rules the merchant has staged and not switched on. Omit it for both. A
            value that is not a boolean is a 400 rather than an ignored filter.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Rules touched at or after this RFC3339 instant. enabled is the field that MOVES
            here: a rule is written once and then switched on and off as a merchant opens and
            closes markets, so the default created_at ordering is frozen over the one thing
            worth tracking and a cached copy keeps routing shoppers by a rule that is now off.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        action:
                          type: string
                        created_at:
                          type: string
                        display:
                          type: object
                          properties:
                            dismissible:
                              type: boolean
                            mode:
                              type: string
                            position:
                              type: string
                        enabled:
                          type: boolean
                        id:
                          type: string
                        match_type:
                          type: string
                        match_values:
                          type: array
                          items:
                            type: string
                        name:
                          type: string
                        priority:
                          type: integer
                        redirect_status:
                          type: integer
                        target_locale:
                          type: [string, "null"]
                        target_region_id:
                          type: [string, "null"]
                        target_url:
                          nullable: true
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a geo rule
      operationId: post_geo_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_geo
      x-required-scopes: [write_geo]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, enabled, priority, match_type, match_values, action]
              additionalProperties: false
              description: >-
                A FULL REPLACE on both routes, which is why the create and the replace
                take the same body and why PUT is the method rather than PATCH. An
                omitted optional key is CLEARED rather than carried over, so read the
                rule and send back what you want it to end up with.
                THE SIX REQUIRED FIELDS INCLUDE enabled AND priority, which look
                defaultable and are not: omitted, they decode to false and 0, and the
                first silently stops a live rule firing while the second promotes it
                ahead of every other rule in the store. Neither raises an error and
                neither is visible until an international shopper sees the wrong
                storefront, so both are refused when absent.
                402 rather than 403 when the merchant's PLAN refuses: the store's rule
                cap, and the redirect_url and block actions. That is not a scope you
                can ask for; only the merchant can change it.
              properties:
                name:
                  type: string
                  description: >-
                    Unique per store, which makes it a stable handle a migration tool
                    can re-run against. A duplicate is 409 rule_exists rather than a
                    second rule.
                enabled:
                  type: boolean
                  description: >-
                    false leaves the rule stored and stops the storefront evaluating
                    it. This is the REVERSIBLE form of removing a rule, and it is
                    reachable on write_geo alone, unlike the DELETE.
                priority:
                  type: integer
                  description: >-
                    Evaluation order, LOWEST FIRST, and the first matching rule wins.
                    Not unique and not a position in the list: the list is paged by
                    creation time precisely because re-prioritising one rule would
                    otherwise shift every rule after it across a page boundary.
                match_type:
                  type: string
                  enum: [country, ip_cidr]
                  description: Decides how match_values is read.
                match_values:
                  type: array
                  items: { type: string }
                  minItems: 1
                  maxItems: 250
                  description: >-
                    Two-letter ISO 3166-1 alpha-2 country codes for a country rule,
                    normalised to uppercase, or CIDR blocks for an ip_cidr one. At
                    least one; an empty array is refused rather than stored as a rule
                    that matches nothing.
                action:
                  type: string
                  enum: [switch_market, redirect_url, banner, popup, block]
                  description: >-
                    switch_market needs target_region_id. redirect_url needs an
                    absolute http(s) target_url. redirect_url and block require a paid
                    plan and are refused 402 on a plan without it, rather than stored
                    and silently ignored.
                target_region_id:
                  type: [string, "null"]
                  description: >-
                    The market a switch_market rule moves the shopper into. A region
                    of this store, listed at /api/v1/regions; a region id from
                    anywhere else is 400 invalid_region rather than 404. Null or
                    omitted on every other action.
                target_url:
                  type: [string, "null"]
                  description: >-
                    Where a redirect_url rule sends the shopper. Absolute, http or
                    https.
                target_locale:
                  type: [string, "null"]
                  description: >-
                    The language a switch_market rule also sets, like en or en-CA.
                    Omit it to leave the shopper's language alone.
                redirect_status:
                  type: integer
                  enum: [301, 302]
                  description: >-
                    Only meaningful on a redirect_url rule. Omit it for 302. Anything
                    other than 301 or 302 is a 400 rather than a silently corrected
                    redirect.
                display:
                  type: object
                  additionalProperties: true
                  description: >-
                    The presentation document for the banner, popup or block surface:
                    colours, position, dismissible, and the switch_market mode. It is
                    sent and served as the stored document rather than as named
                    fields, so a read is round-trippable through this replace; a
                    client that dropped it would blank the merchant's styling and the
                    banner would go plain with nothing to say why. Colours must be
                    strict hex and position must be top or bottom, because both reach
                    inline styles on the storefront. Omitted means {} and therefore
                    the storefront's own defaults.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                      created_at:
                        type: string
                      display:
                        type: object
                        properties:
                          mode:
                            type: string
                          position:
                            type: string
                      enabled:
                        type: boolean
                      id:
                        type: string
                      match_type:
                        type: string
                      match_values:
                        type: array
                        items:
                          type: string
                      name:
                        type: string
                      priority:
                        type: integer
                      redirect_status:
                        type: integer
                      target_locale:
                        type: [string, "null"]
                      target_region_id:
                        type: [string, "null"]
                      target_url:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/geo-rules/{geoRuleId}:
    delete:
      summary: Remove a geo rule
      description: >-
        Removes the rule. There is no restore: the country list, the target market and
        the appearance document go with it, and the storefront keeps serving, so
        shoppers in that rule's countries silently fall through to the store's
        default_action rather than seeing an error. PUT with enabled false is the
        reversible form of the same intent and keeps every field.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_geo alone, so ask the merchant for both.
        IDEMPOTENCY HERE IS PER KEY, NOT PER RESOURCE. Replaying the same
        Idempotency-Key returns the stored 204 without touching anything. A FRESH key
        against a rule that is already gone is 404 rule_not_found, because answering
        204 to a mistyped id would read as a removal that never happened while the
        real rule kept routing shoppers.
      operationId: delete_geo_rules_by_geoRuleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_geo:delete
      x-required-scopes: [write_geo, write_geo:delete]
      parameters:
        - name: geoRuleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_geo and then on write_geo:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_geo, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a geo rule
      description: >-
        One geo rule, in the same shape the list and both writes serve.
      operationId: get_geo_rules_by_geoRuleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_geo
      x-required-scopes: [read_geo]
      parameters:
        - name: geoRuleId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                      created_at:
                        type: string
                      display:
                        type: object
                        properties:
                          dismissible:
                            type: boolean
                          mode:
                            type: string
                          position:
                            type: string
                      enabled:
                        type: boolean
                      id:
                        type: string
                      match_type:
                        type: string
                      match_values:
                        type: array
                        items:
                          type: string
                      name:
                        type: string
                      priority:
                        type: integer
                      redirect_status:
                        type: integer
                      target_locale:
                        type: [string, "null"]
                      target_region_id:
                        type: [string, "null"]
                      target_url:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a geo rule
      operationId: put_geo_rules_by_geoRuleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_geo
      x-required-scopes: [write_geo]
      parameters:
        - name: geoRuleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, enabled, priority, match_type, match_values, action]
              additionalProperties: false
              description: >-
                A FULL REPLACE on both routes, which is why the create and the replace
                take the same body and why PUT is the method rather than PATCH. An
                omitted optional key is CLEARED rather than carried over, so read the
                rule and send back what you want it to end up with.
                THE SIX REQUIRED FIELDS INCLUDE enabled AND priority, which look
                defaultable and are not: omitted, they decode to false and 0, and the
                first silently stops a live rule firing while the second promotes it
                ahead of every other rule in the store. Neither raises an error and
                neither is visible until an international shopper sees the wrong
                storefront, so both are refused when absent.
                402 rather than 403 when the merchant's PLAN refuses: the store's rule
                cap, and the redirect_url and block actions. That is not a scope you
                can ask for; only the merchant can change it.
              properties:
                name:
                  type: string
                  description: >-
                    Unique per store, which makes it a stable handle a migration tool
                    can re-run against. A duplicate is 409 rule_exists rather than a
                    second rule.
                enabled:
                  type: boolean
                  description: >-
                    false leaves the rule stored and stops the storefront evaluating
                    it. This is the REVERSIBLE form of removing a rule, and it is
                    reachable on write_geo alone, unlike the DELETE.
                priority:
                  type: integer
                  description: >-
                    Evaluation order, LOWEST FIRST, and the first matching rule wins.
                    Not unique and not a position in the list: the list is paged by
                    creation time precisely because re-prioritising one rule would
                    otherwise shift every rule after it across a page boundary.
                match_type:
                  type: string
                  enum: [country, ip_cidr]
                  description: Decides how match_values is read.
                match_values:
                  type: array
                  items: { type: string }
                  minItems: 1
                  maxItems: 250
                  description: >-
                    Two-letter ISO 3166-1 alpha-2 country codes for a country rule,
                    normalised to uppercase, or CIDR blocks for an ip_cidr one. At
                    least one; an empty array is refused rather than stored as a rule
                    that matches nothing.
                action:
                  type: string
                  enum: [switch_market, redirect_url, banner, popup, block]
                  description: >-
                    switch_market needs target_region_id. redirect_url needs an
                    absolute http(s) target_url. redirect_url and block require a paid
                    plan and are refused 402 on a plan without it, rather than stored
                    and silently ignored.
                target_region_id:
                  type: [string, "null"]
                  description: >-
                    The market a switch_market rule moves the shopper into. A region
                    of this store, listed at /api/v1/regions; a region id from
                    anywhere else is 400 invalid_region rather than 404. Null or
                    omitted on every other action.
                target_url:
                  type: [string, "null"]
                  description: >-
                    Where a redirect_url rule sends the shopper. Absolute, http or
                    https.
                target_locale:
                  type: [string, "null"]
                  description: >-
                    The language a switch_market rule also sets, like en or en-CA.
                    Omit it to leave the shopper's language alone.
                redirect_status:
                  type: integer
                  enum: [301, 302]
                  description: >-
                    Only meaningful on a redirect_url rule. Omit it for 302. Anything
                    other than 301 or 302 is a 400 rather than a silently corrected
                    redirect.
                display:
                  type: object
                  additionalProperties: true
                  description: >-
                    The presentation document for the banner, popup or block surface:
                    colours, position, dismissible, and the switch_market mode. It is
                    sent and served as the stored document rather than as named
                    fields, so a read is round-trippable through this replace; a
                    client that dropped it would blank the merchant's styling and the
                    banner would go plain with nothing to say why. Colours must be
                    strict hex and position must be top or bottom, because both reach
                    inline styles on the storefront. Omitted means {} and therefore
                    the storefront's own defaults.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      action:
                        type: string
                      created_at:
                        type: string
                      display:
                        type: object
                        properties:
                          dismissible:
                            type: boolean
                          mode:
                            type: string
                          position:
                            type: string
                      enabled:
                        type: boolean
                      id:
                        type: string
                      match_type:
                        type: string
                      match_values:
                        type: array
                        items:
                          type: string
                      name:
                        type: string
                      priority:
                        type: integer
                      redirect_status:
                        type: integer
                      target_locale:
                        type: [string, "null"]
                      target_region_id:
                        type: [string, "null"]
                      target_url:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/geo-settings:
    get:
      summary: Retrieve geo settings
      description: >-
        The store's geo configuration. A SINGLETON: one row per tenant, so there is no
        page block and no id in the path.
        A store that has never configured geo has no row, and this answers with the
        documented defaults (disabled, banner, hreflang on, 30 days) rather than a
        404. "Geolocation is off" is the true answer to the question, and a 404 would
        make you branch on a missing resource to read a boolean you already know.
      operationId: get_geo_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_geo
      x-required-scopes: [read_geo]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      default_action:
                        type: string
                      enabled:
                        type: boolean
                      hreflang_enabled:
                        type: boolean
                      respect_choice_days:
                        type: integer
                      updated_at:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace the store's geo settings
      operationId: put_geo_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_geo
      x-required-scopes: [write_geo]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled, default_action, hreflang_enabled, respect_choice_days, expected_updated_at]
              additionalProperties: false
              description: >-
                REPLACES the whole settings row, and every field is required for that
                reason. An omitted enabled would decode to false and switch
                geolocation off for the entire store, so a client editing one field
                through a body that dropped the others would take the merchant's
                international routing down and read a 200 back. Read
                /api/v1/geo-settings and send every field.
                A store that has never configured geo has no row; this creates it.
                There is no DELETE, because removing the row restores these defaults
                rather than turning the feature off, which enabled false already does.
              properties:
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. Required, because the settings are a
                    singleton written by this route AND by the merchant's dashboard: a
                    write naming no version silently reverts whatever moved under it
                    and answers 200 to both parties. A mismatch is 409
                    settings_modified. null asserts that this store has no settings row
                    yet, which is the only way a first write can name a version that
                    does not exist, and it is refused once a row does exist.
                enabled:
                  type: boolean
                  description: >-
                    The master switch. false means the storefront skips geo entirely
                    and no rule fires, whatever an individual rule's own enabled says.
                default_action:
                  type: string
                  enum: [switch_market, banner, popup]
                  description: >-
                    What happens for a shopper no rule matched. The destructive pair
                    (redirect_url, block) is refused here even on a plan that allows
                    them on an individual rule: a default applies to every unmatched
                    visitor in the world, including the merchant's domestic shoppers
                    and every crawler.
                hreflang_enabled:
                  type: boolean
                  description: >-
                    Emit hreflang alternates for the merchant's markets. An SEO
                    switch, not a routing one.
                respect_choice_days:
                  type: integer
                  minimum: 0
                  maximum: 3650
                  description: >-
                    How long the storefront honours a shopper's own market choice
                    before suggesting again. 0 means do not remember it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      default_action:
                        type: string
                      enabled:
                        type: boolean
                      hreflang_enabled:
                        type: boolean
                      respect_choice_days:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_geo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-deliveries:
    get:
      summary: List gift card deliveries
      operationId: get_gift_card_deliveries
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attempts:
                          type: number
                        created_at:
                          type: string
                        delivery_mode:
                          type: string
                        exhausted:
                          type: boolean
                        max_attempts:
                          type: number
                        next_retry_at:
                          type: string
                        order_line_item_id:
                          type: string
                        send_at:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-deliveries/{lineId}:
    get:
      summary: Retrieve a gift card delivery
      operationId: get_gift_card_deliveries_by_lineId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: lineId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: number
                      created_at:
                        type: string
                      delivery_mode:
                        type: string
                      exhausted:
                        type: boolean
                      max_attempts:
                        type: number
                      next_retry_at:
                        type: string
                      order_line_item_id:
                        type: string
                      send_at:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-deliveries/{lineId}/reschedule:
    post:
      summary: Reschedule a pending gift-card delivery
      operationId: post_gift_card_deliveries_by_lineId_reschedule
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_gift_cards
      x-required-scopes: [write_gift_cards]
      parameters:
        - name: lineId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Moves send_at and delivery_timezone only. It cannot touch the card's
                value or its sealed code. An empty body means "send this now",
                matching the admin route's own retry-button contract.
              properties:
                send_at:
                  type: [string, "null"]
                  format: date-time
                timezone:
                  type: string
                  description: IANA timezone name, e.g. America/Los_Angeles.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: number
                      created_at:
                        type: string
                      delivery_mode:
                        type: string
                      delivery_timezone:
                        type: string
                      exhausted:
                        type: boolean
                      max_attempts:
                        type: number
                      next_retry_at:
                        type: string
                      order_line_item_id:
                        type: string
                      send_at:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-denominations:
    get:
      summary: List gift card denominations
      operationId: get_gift_card_denominations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: product_id
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount:
                          type: number
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        kind:
                          type: string
                        position:
                          type: number
                        product_id:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-denominations/{variantId}:
    get:
      summary: Retrieve a gift card denomination
      operationId: get_gift_card_denominations_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      kind:
                        type: string
                      max_amount:
                        type: number
                      min_amount:
                        type: number
                      position:
                        type: number
                      product_id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-products:
    get:
      summary: List gift card products
      operationId: get_gift_card_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-card-products/{variantId}:
    get:
      summary: Retrieve a gift card product
      operationId: get_gift_card_products_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-cards:
    get:
      summary: List gift cards
      operationId: get_gift_cards
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Cards touched at or after this RFC3339 instant. THE BALANCE IS THE FIELD THAT
            MOVES, and it is money: a card's created_at is written once and never again while
            its balance is rewritten by every redemption, so the default ordering is frozen
            over a figure that changes with spending and a cached list shows a shopper credit
            that is already spent.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE:
            apply the state of each row you receive and a repeat costs nothing. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant rather than dropping the ones your last page did not reach.
            A cursor belongs to ONE ordering and is refused by the other.
            The inclusive bound matters here in particular: a bulk issuance writes many cards
            in ONE statement, so they share a single timestamp.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        balance_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        display_prefix:
                          type: string
                        expires_at:
                          nullable: true
                        id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-cards/settings:
    get:
      summary: Retrieve gift card settings
      operationId: get_gift_cards_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allow_gift_card_tender_on_gift_card_products:
                        type: boolean
                      code_delivery_mode:
                        type: string
                      configured:
                        type: boolean
                      expiry_balance_policy:
                        type: string
                      expiry_mode:
                        type: string
                      refund_allocation_policy:
                        type: string
                      view_token_max_reveals:
                        type: integer
                      view_token_ttl_days:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-cards/{giftCardId}:
    get:
      summary: Retrieve a gift card
      operationId: get_gift_cards_by_giftCardId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: giftCardId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      balance_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      display_prefix:
                        type: string
                      expires_at:
                        type: string
                      id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/gift-cards/{giftCardId}/transactions:
    get:
      summary: List a gift card's transactions
      operationId: get_gift_cards_by_giftCardId_transactions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: giftCardId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        gift_card_id:
                          type: string
                        id:
                          type: string
                        order_id:
                          type: [string, "null"]
                        type:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/config:
    get:
      summary: Retrieve india gst config
      description: >-
        This store's India GST supplier registration: the GSTIN, the registered legal name
        and address, the default rate for unclassified goods, and the invoice-number prefix.
        A STORE THAT HAS NOT CONFIGURED GST IS A 404, which is a state to branch on rather
        than an error. There is no empty document to read: a configuration with no GSTIN is
        not a registration, and one would fail validation if you sent it back.
        EVERY FIELD HERE IS STAMPED ONTO INVOICES ISSUED AFTER IT. A wrong GSTIN or legal
        name does not error, it prints wrong legal documents, and the failure surfaces
        months later at filing.
        state_code is DERIVED from the GSTIN's own first two digits. It decides the tax
        split on every invoice, so it cannot be set independently: see the PUT.
        updated_at is ALSO THE VERSION TOKEN. Send it back as expected_updated_at on the
        PUT and your write refuses rather than reverting a change you never saw.
      operationId: get_india_gst_config
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_india_gst
      x-required-scopes: [read_india_gst]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line1:
                        type: string
                      address_line2:
                        type: string
                      city:
                        type: string
                      created_at:
                        type: [string, "null"]
                      default_rate_bps:
                        type: integer
                      enabled:
                        type: boolean
                      gstin:
                        type: [string, "null"]
                      invoice_prefix:
                        type: string
                      legal_name:
                        type: [string, "null"]
                      postal_code:
                        type: string
                      state_code:
                        type: [string, "null"]
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace the India GST supplier registration
      description: >-
        Replaces the supplier registration whole, creating it when the store has none.
        A FULL REPLACE. An omitted optional key is CLEARED, not carried over, and the five
        value fields are required rather than defaulted: a dropped enabled would take the
        merchant's invoicing down and a dropped default_rate_bps would read as 0 and tax
        every unclassified line at nothing, both with a 200 back.
        expected_updated_at IS REQUIRED and null is a legal value meaning "this store has no
        configuration yet". This row has more than one writer, and what a lost write costs
        here is not a wrong screen: the document is stamped onto every invoice issued
        afterwards. A mismatch is 409 config_modified.
        state_code AND created_by ARE REFUSED BY NAME. The state comes from the validated
        GSTIN, and the actor is the api key that made the call.
      operationId: put_india_gst_config
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_india_gst
      x-required-scopes: [write_india_gst]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [gstin, legal_name, default_rate_bps, invoice_prefix, enabled, expected_updated_at]
              additionalProperties: false
              description: >-
                A FULL REPLACE and an upsert: a store with no registration gets one. An
                omitted optional key is CLEARED rather than carried over, so read the config
                and send every field back.
                THE FIVE VALUE FIELDS ARE NOT DEFAULTED. A dropped enabled would switch the
                merchant's invoicing off and a dropped default_rate_bps would read as 0 and
                tax every unclassified line at nothing, both answering 200.
                expected_updated_at IS REQUIRED, and null is the legal value meaning "this
                store has no configuration yet". A mismatch is 409 config_modified. This row
                has more than one writer and it is stamped onto every invoice issued after
                it, so a blind write does not lose a screen's worth of edits, it mints wrong
                legal documents until somebody notices at filing.
                state_code and created_by are REFUSED BY NAME rather than ignored. The state
                is derived from the validated GSTIN, and a state that disagreed with the
                registration would split every invoice against the wrong one; the actor is
                the api key that made the call.
              properties:
                gstin:
                  type: string
                  description: >-
                    The 15-character registration, for example 27AAPFU0939F1ZV. Validated
                    for structure, GST state and the mod-36 check digit, and stored
                    upper-cased. Its first two digits become state_code.
                legal_name:
                  type: string
                  maxLength: 200
                  description: >-
                    The registered entity name that prints on the invoice, which is often
                    not the store's trading name.
                address_line1:
                  type: string
                  description: Registered address. Optional, and CLEARED when omitted.
                address_line2:
                  type: string
                  description: Registered address. Optional, and CLEARED when omitted.
                city:
                  type: string
                  description: Registered address. Optional, and CLEARED when omitted.
                postal_code:
                  type: string
                  description: Registered address. Optional, and CLEARED when omitted.
                default_rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 10000
                  description: >-
                    The rate applied to a line whose product carries no HSN classification,
                    in BASIS POINTS: 18% is 1800, never 18. Zero is legal and means
                    unclassified lines are taxed at nothing.
                invoice_prefix:
                  type: string
                  maxLength: 6
                  description: >-
                    1 to 6 uppercase letters or digits, leading every generated invoice
                    number (INV in INV/252600001). Changing it renumbers nothing already
                    issued, so a store's history can carry more than one prefix.
                enabled:
                  type: boolean
                  description: >-
                    The master switch. false refuses every issue with 409 gst_disabled and
                    keeps every other field, which is the reversible operation this family
                    offers instead of a delete.
                expected_updated_at:
                  type: [string, "null"]
                  format: date-time
                  description: >-
                    The updated_at you read from GET /api/v1/india-gst/config, or null if
                    you expect this store to have no configuration yet. Anything else is
                    409.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line1:
                        type: string
                      address_line2:
                        type: string
                      city:
                        type: string
                      created_at:
                        type: string
                      default_rate_bps:
                        type: integer
                      enabled:
                        type: boolean
                      gstin:
                        type: string
                      invoice_prefix:
                        type: string
                      legal_name:
                        type: string
                      postal_code:
                        type: string
                      state_code:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/invoices:
    get:
      summary: List india gst invoices
      description: >-
        The store's statutory GST tax invoices, newest first. This is the feed a GSTR-1
        return is assembled from, and it is the reason this family is published: an invoice
        was previously reachable only by its own id, which nobody preparing a return has.
        THREE FILTERS, NOT THREE ROUTES. ?financial_year= is the filing period,
        ?order_id= reconciles one order against its document, and ?status= separates the
        invoices that stand from the ones that were voided.
        A CANCELLED INVOICE IS NOT HIDDEN by default, and must not be treated as absent. The
        void is part of the merchant's record: the number was really drawn from the series
        and a return that silently omitted it would be wrong.
        Amounts are integers in the MINOR UNIT of currency_code, which comes from the order
        the invoice was issued against. Exactly one of the cgst/sgst pair and igst is
        non-zero: intra-state supplies split CGST and SGST, inter-state supplies carry IGST.
        Each row carries its LINES, because a per-line HSN and rate breakdown is what a
        return needs and fetching it per invoice would be a request per document.
      operationId: get_india_gst_invoices
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_india_gst
      x-required-scopes: [read_india_gst]
      parameters:
        - name: financial_year
          in: query
          required: false
          schema: { type: string, pattern: '^\d{4}-\d{2}$' }
          description: >-
            The Indian financial year as YYYY-YY, for example 2025-26 for 1 April 2025 to
            31 March 2026. This is the filing period, and the invoice sequence restarts
            each year, so a number is only unique together with it. A value in any other
            shape is a 400 rather than an empty page, because "2025-2026" matching nothing
            would read as "this store issued no invoices that year".
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            EXACT match on one order, which is how you reconcile an order against its
            document. An order with no invoice returns an empty page. An order can have
            more than one row here over time: cancelling frees its slot, so a corrected
            order carries the void and the replacement.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, cancelled] }
          description: >-
            active is an invoice that still stands, cancelled is one that was voided. A
            value outside the enum is a 400 rather than an unfiltered page, so a
            misspelled "canceled" cannot silently return everything.
            OMIT IT FOR A RETURN. Unfiltered includes the voided invoices, and they belong
            in the merchant's record: the number was really drawn from the series.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        cancelled_at:
                          nullable: true
                        cgst_amount:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        financial_year:
                          type: string
                        id:
                          type: string
                        igst_amount:
                          type: integer
                        invoice_number:
                          type: string
                        lines:
                          type: array
                          items:
                            type: object
                            properties:
                              cgst_amount:
                                type: integer
                              description:
                                type: string
                              gst_rate_bps:
                                type: integer
                              hsn_code:
                                type: string
                              igst_amount:
                                type: integer
                              quantity:
                                type: integer
                              sgst_amount:
                                type: integer
                              taxable_amount:
                                type: integer
                        order_id:
                          type: string
                        place_of_supply:
                          type: string
                        sgst_amount:
                          type: integer
                        supplier_gstin:
                          type: string
                        taxable_amount:
                          type: integer
                        total_amount:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Issue the GST tax invoice for an order
      description: >-
        Issues the GST tax invoice for an order, computing the lines, the rates and the
        CGST/SGST/IGST split from the order itself and drawing a number from a GAPLESS
        per-financial-year sequence.
        IT MINTS A LEGAL DOCUMENT. Unlike every other create on this API it cannot be undone
        by removing the row, because the series has already moved.
        201 EVEN ON A REPEAT, and the body is the invoice NOW IN FORCE. An order that
        already has a standing invoice gets that one back without consuming a second number,
        so a retrying pipeline cannot punch holes in the sequence. Compare the id you get
        back if you need to know which happened; branching the status on it would tell a key
        holding only the write grant whether an invoice exists, which is a read.
        THE STORE MUST HAVE GST ENABLED. A store with no configuration, or one with
        enabled false, is refused 409 rather than issuing an untaxed document.
        EVERYTHING THE DOCUMENT ASSERTS ABOUT ITSELF IS REFUSED BY NAME: the number, the
        financial year, the five amounts, the lines and issued_by are computed or stamped
        here. Only order_id and place_of_supply are yours to send. issued_by is stamped and
        NOT published back: the column holds a merchant staff user id on a dashboard-issued
        invoice, and a scope over tax documents is not a grant over the staff directory.
      operationId: post_india_gst_invoices
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_india_gst
      x-required-scopes: [write_india_gst]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id]
              additionalProperties: false
              description: >-
                Computes the lines, rates and CGST/SGST/IGST split from the order and draws
                a number from a GAPLESS per-financial-year sequence.
                201 EVEN ON A REPEAT, and the body is the invoice NOW IN FORCE: an order
                with a standing invoice gets that one back without consuming a second
                number. Compare the id if you need to know which happened; branching the
                status on it would tell a key holding only the write grant whether an
                invoice exists.
                THE STORE MUST HAVE GST ENABLED, or the call is 409 rather than an untaxed
                document.
                EVERYTHING THE DOCUMENT ASSERTS ABOUT ITSELF IS REFUSED BY NAME:
                invoice_number, financial_year, the five amounts, lines, currency_code,
                supplier_gstin and issued_by are computed or stamped by the server. A
                caller-supplied number would claim a place in a series the counter never
                issued, and a caller-supplied issued_by would make an api write
                indistinguishable from a person issuing from the dashboard.
                issued_by IS STAMPED BUT NEVER PUBLISHED BACK. The same column holds a
                merchant staff user id on any invoice issued from the dashboard, so reading
                it would answer "who works here" from a scope granted over tax documents.
              properties:
                order_id:
                  type: string
                  description: >-
                    The order to invoice. It must belong to this store, and its own line
                    items are what the invoice is computed from.
                place_of_supply:
                  type: string
                  description: >-
                    The RECIPIENT's two-digit GST state code, 01 to 38. Equal to the
                    supplier's state means an intra-state supply and a CGST/SGST split;
                    anything else means IGST. Omit it for the supplier's own state. A code
                    outside the set is a 400 rather than a guess, because guessing wrong
                    taxes the supply under the wrong heads.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled_at:
                        nullable: true
                      cgst_amount:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      financial_year:
                        type: string
                      id:
                        type: string
                      igst_amount:
                        type: integer
                      invoice_number:
                        type: string
                      lines:
                        type: array
                        items:
                          type: object
                          properties:
                            cgst_amount:
                              type: integer
                            description:
                              type: string
                            gst_rate_bps:
                              type: integer
                            hsn_code:
                              type: string
                            igst_amount:
                              type: integer
                            quantity:
                              type: integer
                            sgst_amount:
                              type: integer
                            taxable_amount:
                              type: integer
                      order_id:
                        type: string
                      place_of_supply:
                        type: string
                      sgst_amount:
                        type: integer
                      supplier_gstin:
                        type: string
                      taxable_amount:
                        type: integer
                      total_amount:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/invoices/{invoiceId}:
    get:
      summary: Retrieve an india gst invoice
      description: >-
        One tax invoice, addressed by its id. The same shape the list serves.
        supplier_gstin is the registration AS IT STOOD when this invoice was issued, read
        from an immutable snapshot rather than from the store's current configuration: a
        merchant who corrects their GSTIN must not have that correction rewrite documents
        already in a buyer's hands. The lines and the currency come from the same snapshot.
        irn AND qr_payload ARE NOT PUBLISHED. e-invoice (IRP) registration is not built, so
        those columns are null on every invoice; they join this resource when it ships.
        issued_by IS NOT PUBLISHED EITHER. The server stamps it, but the column holds a
        merchant staff user id on any dashboard-issued invoice, and this scope is a grant
        over tax documents rather than over the people who work at the store.
      operationId: get_india_gst_invoices_by_invoiceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_india_gst
      x-required-scopes: [read_india_gst]
      parameters:
        - name: invoiceId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled_at:
                        nullable: true
                      cgst_amount:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      financial_year:
                        type: string
                      id:
                        type: string
                      igst_amount:
                        type: integer
                      invoice_number:
                        type: string
                      lines:
                        type: array
                        items:
                          type: object
                          properties:
                            cgst_amount:
                              type: integer
                            description:
                              type: string
                            gst_rate_bps:
                              type: integer
                            hsn_code:
                              type: string
                            igst_amount:
                              type: integer
                            quantity:
                              type: integer
                            sgst_amount:
                              type: integer
                            taxable_amount:
                              type: integer
                      order_id:
                        type: string
                      place_of_supply:
                        type: string
                      sgst_amount:
                        type: integer
                      supplier_gstin:
                        type: string
                      taxable_amount:
                        type: integer
                      total_amount:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/invoices/{invoiceId}/cancel:
    post:
      summary: Void an issued GST tax invoice
      description: >-
        Voids an issued invoice and answers it with cancelled_at set.
        THIS NEEDS write_india_gst:delete IN ADDITION TO write_india_gst. There is no
        un-cancel: the void frees the order's slot, so putting the order right means issuing
        a SECOND invoice that consumes another number, and the buyer's copy of the original
        stops matching the merchant's books. Nothing errors and every read still answers
        200; the loss surfaces in the return the merchant files.
        A POST RATHER THAN A DELETE because nothing is removed. The row keeps its number and
        stays readable and listed forever, which is what makes the void auditable.
        AN ALREADY-CANCELLED INVOICE IS 409, not 404 and not a second success. Retrying a
        cancel that already landed must not read as "this invoice never existed", because
        the two call for opposite next steps.
        The cancel is still idempotent BY KEY: replaying an Idempotency-Key returns the
        stored 200. It is a NEW key against a voided invoice that answers 409.
      operationId: post_india_gst_invoices_by_invoiceId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_india_gst:delete
      x-required-scopes: [write_india_gst, write_india_gst:delete]
      parameters:
        - name: invoiceId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The response is the
                invoice with cancelled_at set.
                THIS NEEDS write_india_gst:delete AS WELL AS write_india_gst. There is no
                un-cancel. The void frees the order's slot, so putting the order right means
                issuing a SECOND invoice that consumes another number, and the buyer's copy
                of the original stops matching the merchant's books. Nothing errors and
                every read still answers 200; the loss surfaces in the return the merchant
                files.
                A POST RATHER THAN A DELETE because nothing is removed: the row keeps its
                number and stays readable and listed forever, which is what makes the void
                auditable.
                AN ALREADY-CANCELLED INVOICE IS 409, not 404 and not a second success.
                Retrying a cancel that already landed must not read as "this invoice never
                existed", because the two call for opposite next steps.
                USE A FRESH Idempotency-Key. A replayed key returns the stored 200 without
                re-running anything.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled_at:
                        type: string
                      cgst_amount:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      financial_year:
                        type: string
                      id:
                        type: string
                      igst_amount:
                        type: integer
                      invoice_number:
                        type: string
                      lines:
                        type: array
                        items:
                          type: object
                          properties:
                            cgst_amount:
                              type: integer
                            description:
                              type: string
                            gst_rate_bps:
                              type: integer
                            hsn_code:
                              type: string
                            igst_amount:
                              type: integer
                            quantity:
                              type: integer
                            sgst_amount:
                              type: integer
                            taxable_amount:
                              type: integer
                      order_id:
                        type: string
                      place_of_supply:
                        type: string
                      sgst_amount:
                        type: integer
                      supplier_gstin:
                        type: string
                      taxable_amount:
                        type: integer
                      total_amount:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_india_gst and then on write_india_gst:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_india_gst, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/product-hsn:
    get:
      summary: List india gst product hsn
      description: >-
        The store's HSN/SAC classifications: which code and which GST rate each product is
        taxed under. Rates are BASIS POINTS, so 12% is 1200.
        A PRODUCT WITH NO ROW HERE IS NORMAL. The invoice generator falls back to HSN 9999
        at the supplier's default_rate_bps, so most catalogues carry rows for a minority of
        products and the absence is a configuration rather than a gap.
        THERE IS NO GET BY PRODUCT because ?product_id= answers the same question better.
        "Is this product classified" returns an empty page when it is not, which is the
        correct answer; a 404 would be an error status for the ordinary outcome and every
        client would have to treat it as success.
        The resource is keyed by product_id and publishes no other id, because that is what
        every route addressing one takes.
      operationId: get_india_gst_product_hsn
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_india_gst
      x-required-scopes: [read_india_gst]
      parameters:
        - name: product_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            EXACT match on one product, which is how you ask "is this product classified"
            before an import. A product with no classification returns an empty page,
            which is the answer rather than an error: the invoice generator falls back to
            HSN 9999 at the store's default rate, so having no row is a configuration.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        gst_rate_bps:
                          type: integer
                        hsn_code:
                          type: string
                        product_id:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/india-gst/product-hsn/{productId}:
    put:
      summary: Classify a product for GST
      description: >-
        Sets a product's HSN/SAC code and its GST rate, creating the classification or
        replacing it. There is no POST: a product has at most one, so there is no second
        one to create.
        BOTH FIELDS ARE REQUIRED AND NEITHER IS DEFAULTED. An omitted gst_rate_bps would
        read as 0 and make the product exempt, which is a real configuration and therefore
        indistinguishable from a bug on every later read.
        THIS CHANGES WHAT FUTURE INVOICES CHARGE. It does not touch documents already
        issued: their rates are frozen into the invoice at issue time.
        A product this store does not own is a 404.
        THERE IS NO DELETE. Removing a classification does not fail anything, it silently
        returns the product to HSN 9999 at the store default, so the next invoice is taxed
        differently with nothing looking changed. Correct the code here instead.
      operationId: put_india_gst_product_hsn_by_productId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_india_gst
      x-required-scopes: [write_india_gst]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hsn_code, gst_rate_bps]
              additionalProperties: false
              description: >-
                An upsert keyed by the PRODUCT in the path, so there is no POST: a product
                has at most one classification.
                BOTH FIELDS ARE REQUIRED AND NEITHER IS DEFAULTED. An omitted rate would
                read as 0 and make the product exempt, which is a real configuration and so
                indistinguishable from a bug on every later read.
                IT CHANGES WHAT FUTURE INVOICES CHARGE and touches no document already
                issued: rates are frozen into the invoice at issue time.
                A product this store does not own is a 404. product_id is refused in the
                body: the product is named by the path.
              properties:
                hsn_code:
                  type: string
                  description: >-
                    The HSN or SAC code, 4 to 8 digits. How many digits a return must carry
                    is the merchant's own compliance decision, so this is stored as given
                    rather than padded or truncated.
                gst_rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 10000
                  description: >-
                    This product's rate in BASIS POINTS: 12% is 1200. It overrides the
                    store default for every line of this product, including a rate of zero,
                    which is how an exempt product is expressed.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      gst_rate_bps:
                        type: integer
                      hsn_code:
                        type: string
                      product_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_india_gst scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/inventory-levels:
    get:
      summary: List inventory levels
      operationId: get_inventory_levels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: variant_id
          in: query
          required: false
          schema: { type: string }
          description: One variant's stock across every location.
        - name: location_id
          in: query
          required: false
          schema: { type: string }
          description: Everything held at one location.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Levels touched at or after this RFC3339 instant. THIS IS THE LIST WHERE IT
            MATTERS MOST: a level's created_at is written once, when a variant first stocks
            at a location, and never again, while its quantity is rewritten by every sale,
            restock, reservation and transfer. Without this the default ordering is frozen
            while the data churns, and reconciling stock means re-walking the whole catalogue
            on a schedule and still being slightly behind. Supplying it CHANGES THE ORDER of
            this endpoint: the default walk descends created_at, and this one climbs
            updated_at, which is the only ordering in which a keyset walk over a column this
            volatile cannot skip a row. A level that moves mid-walk is handed to you again,
            so the walk is AT LEAST ONCE: apply each row you receive and a repeat costs
            nothing, while a miss means selling stock that is already gone. The bound is
            INCLUSIVE, and that matters here more than anywhere: a bulk restock or a transfer
            commit writes many levels in ONE statement, so they share a single timestamp, and
            an exclusive bound would drop every one your last page did not reach. A cursor
            belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        available_quantity:
                          type: integer
                        created_at:
                          type: string
                        id:
                          type: string
                        location_id:
                          type: string
                        reserved_quantity:
                          type: integer
                        stocked_quantity:
                          type: integer
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a variant's stocked quantity at one location
      operationId: put_inventory_levels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, location_id, stocked_quantity]
              additionalProperties: false
              properties:
                variant_id:
                  type: string
                location_id:
                  type: string
                stocked_quantity:
                  type: integer
                  minimum: 0
                  description: >-
                    The absolute quantity, not a delta. Sending the same request
                    twice converges on the same stock, so a retry after a timeout
                    cannot double-apply.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available_quantity:
                        type: integer
                      created_at:
                        type: string
                      id:
                        type: string
                      location_id:
                        type: string
                      reserved_quantity:
                        type: integer
                      stocked_quantity:
                        type: integer
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/inventory-levels/{levelId}:
    get:
      summary: Retrieve an inventory level
      operationId: get_inventory_levels_by_levelId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: levelId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available_quantity:
                        type: integer
                      created_at:
                        type: string
                      id:
                        type: string
                      location_id:
                        type: string
                      reserved_quantity:
                        type: integer
                      stocked_quantity:
                        type: integer
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/klaviyo:
    get:
      summary: Retrieve klaviyo
      operationId: get_klaviyo
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_klaviyo
      x-required-scopes: [read_klaviyo]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_klaviyo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/klaviyo/site-id:
    get:
      summary: Retrieve klaviyo site id
      operationId: get_klaviyo_site_id
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_klaviyo
      x-required-scopes: [read_klaviyo]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      site_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_klaviyo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set or clear the store's public Klaviyo site ID
      operationId: put_klaviyo_site_id
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_klaviyo
      x-required-scopes: [write_klaviyo]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site_id]
              additionalProperties: false
              description: >-
                site_id IS REQUIRED BY NAME rather than defaulting to empty when
                omitted: sending an empty string clears the value on purpose, and an
                omitted field must not collapse into the same request. The value is
                public by design, the identifier baked into the onsite script URL
                every shopper's browser fetches, and this API already serves it
                unauthenticated at the storefront config route.
              properties:
                site_id:
                  type: string
                  description: 4 to 32 letters and digits, or empty to clear it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      site_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_klaviyo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/locales:
    get:
      summary: List locales
      operationId: get_locales
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_translations
      x-required-scopes: [read_translations]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                        created_at:
                          type: string
                        is_default:
                          type: boolean
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_translations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/low-stock-thresholds:
    get:
      summary: List low stock thresholds
      description: >-
        The reorder thresholds this store has armed, newest first: the total stocked quantity,
        summed across every location, at or below which the store owner is emailed that a
        variant is running down.
        THIS IS THE MERCHANT'S OWN NOTION OF LOW, not a stock level. How many units exist is
        read_inventory and changes on every order; this is a preference that changes rarely,
        which is why the two are separate grants and why a replenishment tool wants this one
        without being handed a live feed of the other.
        ONE THRESHOLD PER VARIANT AT MOST, so a store that armed its catalogue has as many
        rows as it has variants. Page it; do not assume one response holds them all.
        notified is the notify-once LATCH: true means the owner has already been told and will
        not be told again until stock recovers above the threshold. Read it before you raise a
        purchase suggestion, or every sync re-raises one the merchant has already acted on. It
        says nothing about when mail was sent or to whom, and no send time is published.
        A threshold of ZERO IS REAL and means "tell me when this variant is actually out". It
        is not the absence of a threshold. Absence is the row not being here at all, which is
        what DELETE produces.
        NO FILTERS. ?notified= is the one that looks useful and it would page a set that
        changes underneath you every time stock crosses a threshold; filter the page instead.
        ?updated_after= is not a filter but an ordering: see its own description.
      operationId: get_low_stock_thresholds
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_low_stock
      x-required-scopes: [read_low_stock]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Thresholds touched at or after this RFC3339 instant, for reconciling reorder points
            incrementally instead of re-reading every armed variant on every run. Supplying it
            CHANGES THE ORDER of this endpoint: the default walk descends created_at, and this one
            climbs updated_at, because that is the only ordering in which a keyset walk over a
            mutable column cannot skip a row. A threshold that changes mid-walk moves forward past
            your position and is returned again, so the walk is AT LEAST ONCE: apply the state of
            each row you receive and a repeat costs nothing. NOTE THAT THE LATCH MOVES IT TOO:
            updated_at is rewritten when notified flips, not only when the merchant re-arms a
            threshold, which is deliberate, because notified is published and a replenishment tool
            that missed the flip would re-raise a purchase suggestion the merchant has acted on. A
            cursor from one ordering is refused by the other, since the same position means
            something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        notified:
                          type: boolean
                        threshold:
                          type: integer
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_low_stock scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/low-stock-thresholds/{variantId}:
    delete:
      summary: Disarm a variant's low-stock alert
      operationId: delete_low_stock_thresholds_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_low_stock:delete
      x-required-scopes: [write_low_stock, write_low_stock:delete]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_low_stock and then on write_low_stock:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_low_stock, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a low stock threshold
      description: >-
        One variant's reorder threshold, in the same shape the list serves, so a row read from
        a page can be re-read on its own rather than by walking the list again.
        THE PATH TAKES THE VARIANT ID. This resource has no id of its own: the row is keyed by
        store and variant, and the variant id is the same value read_products and
        read_inventory hand you.
        A VARIANT WITH NO THRESHOLD IS A 404, never a row with threshold 0. Zero is a legal
        threshold here, so a synthesised default would make "not armed" and "armed to alert at
        zero" the same answer. A variant belonging to another store is a 404 too.
      operationId: get_low_stock_thresholds_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_low_stock
      x-required-scopes: [read_low_stock]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      notified:
                        type: boolean
                      threshold:
                        type: integer
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_low_stock scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Arm or move a variant's reorder threshold
      operationId: put_low_stock_thresholds_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_low_stock
      x-required-scopes: [write_low_stock]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [threshold]
              additionalProperties: false
              description: >-
                An UPSERT keyed on the variant in the path: a variant with no threshold gets
                one, a variant with one has it replaced. The variant is NOT accepted in the
                body, because a body disagreeing with the path would have to pick one and you
                could not tell which.
                IT IS SAFE TO RE-SEND. A threshold that already holds the value you sent is
                left exactly as it is, so a reconciler that pushes its whole list every night
                does not re-arm the alert and does not re-mail the merchant. Only a CHANGED
                value re-evaluates the variant.
                ARMING AN ALREADY-LOW VARIANT ALERTS IMMEDIATELY, inside this request. The
                response reports the state AFTER that alert, so notified is true on the way
                back and you do not need to re-read to discover it.
                A variant that is not this store's is a 404. Removing a threshold is DELETE on
                this path and needs write_low_stock:delete as well as write_low_stock.
              properties:
                threshold:
                  type: integer
                  minimum: 0
                  description: >-
                    The total stocked quantity, SUMMED ACROSS EVERY LOCATION, at or below
                    which the store owner is emailed. It is not per location: a per-location
                    threshold would alert on an empty shelf while the warehouse is full.
                    ZERO IS A REAL VALUE and means "tell me only when this variant is actually
                    out". It is not a way to turn the alert off; DELETE is. A negative value is
                    a 400.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      notified:
                        type: boolean
                      threshold:
                        type: integer
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_low_stock scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/loyalty/accounts:
    get:
      summary: List loyalty accounts
      operationId: get_loyalty_accounts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_loyalty
      x-required-scopes: [read_loyalty]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        balance_points:
                          type: integer
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_loyalty scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/loyalty/accounts/{customerId}:
    get:
      summary: Retrieve a loyalty account
      operationId: get_loyalty_accounts_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_loyalty
      x-required-scopes: [read_loyalty]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      balance_points:
                        type: integer
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_loyalty scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/loyalty/config:
    get:
      summary: Retrieve loyalty config
      operationId: get_loyalty_config
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_loyalty
      x-required-scopes: [read_loyalty]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      earn_points_per_unit:
                        type: integer
                      enabled:
                        type: boolean
                      redeem_value_per_point_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_loyalty scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns:
    get:
      summary: List marketing campaigns
      description: >-
        The store's marketing campaigns, newest first.
        IT DOES NOT CARRY THE CAMPAIGN BODY, and that is a size bound rather than a
        permission one: a body may be 500 KB and a page may hold 100 rows, so a list
        that served them could answer one request with fifty megabytes of markup. Read
        a single campaign to get its copy.
        ?status= and ?channel= narrow it, so "what is sending right now" is one call.
        An unknown value for either is a 400 rather than the unfiltered list, because
        "no paused campaign" and "there is no such status" are different facts and a
        client told the first stops looking.
        enqueued_count IS NOT DELIVERY. It counts the messages a campaign handed to the
        sending spine; the spine's own compliance gate can still decline an individual
        recipient, and the stats route is where that shows up.
      operationId: get_marketing_campaigns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, scheduled, sending, paused, sent, cancelled] }
          description: >-
            Filter by lifecycle state. sending and paused are the two a monitoring
            client polls; sent and cancelled are terminal, so a campaign in either will
            not move again. A value outside the enum is a 400 naming the legal set.
        - name: channel
          in: query
          required: false
          schema: { type: string, enum: [email, sms, push] }
          description: >-
            Filter by transport. A value outside the enum is a 400 rather than an empty
            page, which would read as "this store sends no email".
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        channel:
                          type: string
                        completed_at:
                          nullable: true
                        created_at:
                          type: string
                        enqueued_count:
                          type: integer
                        id:
                          type: string
                        name:
                          type: string
                        scheduled_for:
                          nullable: true
                        segment_id:
                          type: string
                        started_at:
                          nullable: true
                        status:
                          type: string
                        subject:
                          type: string
                        template_id:
                          nullable: true
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Author a marketing campaign
      operationId: post_marketing_campaigns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, channel, segment_id]
              additionalProperties: false
              description: >-
                A campaign ALWAYS lands as a draft and this route can never mail
                anybody: arming it is a second, explicitly named call. status is
                refused by name for that reason.
              properties:
                name:
                  type: string
                  description: >-
                    What the merchant sees in their own dashboard. Unique per store, so
                    a repeat is 409 name_taken rather than a second send nobody can tell
                    apart.
                channel:
                  type: string
                  enum: [email, sms, push]
                  description: >-
                    Which transport the message spine dispatches on. It cannot be
                    inferred and has no default.
                segment_id:
                  type: string
                  description: >-
                    The audience, RESOLVED AT SEND TIME rather than now, so widening the
                    segment later widens who receives this. The segment must already
                    exist in the store; anything else is 422 unprocessable_reference with
                    resource "segment" and field "segment_id", which is also what another
                    tenant's segment id looks like. It is NOT a 404: the URL addressed a
                    real thing, and it is an id inside the body that resolves to nothing.
                subject:
                  type: string
                  description: The subject line, at most 500 bytes.
                body:
                  type: string
                  description: >-
                    The message copy, at most 500 KB. It is served by the single-campaign
                    read and deliberately withheld from the list.
                template_id:
                  type: [string, "null"]
                  description: >-
                    When set, the renderer supplies the content and subject and body are
                    the fallback. null means this campaign carries its own copy.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}:
    delete:
      summary: Remove a draft campaign and its variants
      operationId: delete_marketing_campaigns_by_campaignId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns:delete
      x-required-scopes: [write_marketing_campaigns, write_marketing_campaigns:delete]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_marketing_campaigns and then on write_marketing_campaigns:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_marketing_campaigns, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a marketing campaign
      description: >-
        One campaign, including its body, which is the only place this API serves it.
        READ status TOGETHER WITH scheduled_for. Unscheduling a campaign returns it to
        draft but LEAVES scheduled_for populated, so a client that decided "is this
        armed" from the timestamp alone would report a disarmed campaign as armed
        forever. status is the answer; scheduled_for is when it was, or would have
        been, due.
        completed_at stays null on a cancelled campaign, which is how an abandoned send
        is told apart from a finished one.
      operationId: get_marketing_campaigns_by_campaignId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Edit a draft or scheduled campaign
      operationId: patch_marketing_campaigns_by_campaignId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value, which
                is why this is a PATCH: sending the whole resource would let an omitted
                subject and body blank the campaign, and it would then go out empty to
                everyone in the segment.
                CONTENT IS FROZEN ONCE A SEND STARTS. Only a draft or scheduled campaign
                is editable; anything else is 409 invalid_transition, so a half-sent
                campaign cannot carry two different bodies.
              properties:
                name:
                  type: string
                  description: Still unique per store; a clash is 409 name_taken.
                channel:
                  type: string
                  enum: [email, sms, push]
                segment_id:
                  type: string
                  description: >-
                    Moving the audience. Re-validated against the store, so a segment
                    deleted since this campaign was authored is 422 unprocessable_reference
                    with resource "segment" and field "segment_id", not a 404: the campaign
                    the caller addressed still exists.
                subject:
                  type: string
                body:
                  type: string
                template_id:
                  type: [string, "null"]
                  description: >-
                    THE ONLY KEY ON THIS BODY THAT MAY BE SENT AS NULL, and null CLEARS
                    it: the campaign stops rendering the stored template and falls back
                    to its own subject and body. Omitting the key leaves it alone. Every
                    other field refuses null, because for them a null is a misspelling
                    rather than an intent.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/cancel:
    post:
      summary: Abandon a campaign permanently
      operationId: post_marketing_campaigns_by_campaignId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns:delete
      x-required-scopes: [write_marketing_campaigns, write_marketing_campaigns:delete]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters.
                TERMINAL AND IRREVERSIBLE. There is no path out of cancelled: the untail
                of the audience is never mailed and cannot be resumed, and the campaign
                id cannot be reused because its messages are already filed under it.
                Pause is the reversible operation.
                IT DOES NOT RECALL WHAT WAS ALREADY SENT. Once a message is handed to the
                spine, delivery belongs to the spine.
                NEEDS BOTH GRANTS. The qualified scope named above is refused to a key
                holding write_marketing_campaigns alone, so ask the merchant for both.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_marketing_campaigns and then on write_marketing_campaigns:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_marketing_campaigns, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/pause:
    post:
      summary: Stop an in-flight send between batches
      operationId: post_marketing_campaigns_by_campaignId_pause
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. It is NOT instantaneous: the worker stops at its
                next batch boundary, so a few hundred more recipients may still be
                enqueued after the 200. Everything past that point is still reachable
                with resume, which is what makes this the reversible twin of cancel.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/resume:
    post:
      summary: Continue a paused send
      operationId: post_marketing_campaigns_by_campaignId_resume
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. The 200 means "queued to continue" rather than
                "continuing now": the scheduler picks the campaign up on its next pass
                and continues from where the pause left it. Call send instead to run a
                pass inline.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/schedule:
    post:
      summary: Arm a campaign for a future send
      operationId: post_marketing_campaigns_by_campaignId_schedule
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheduled_for]
              additionalProperties: false
              properties:
                scheduled_for:
                  type: string
                  format: date-time
                  description: >-
                    An RFC 3339 instant IN THE FUTURE; a past or missing one is 400
                    invalid_schedule. A campaign armed for a time already gone would be
                    claimed on the scheduler's very next pass, which is a send wearing a
                    schedule and removes the window unschedule depends on.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        type: string
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/send:
    post:
      summary: Send a campaign now
      operationId: post_marketing_campaigns_by_campaignId_send
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                IT MAILS REAL PEOPLE AND THERE IS NO UNSEND. The audience is whatever the
                targeted segment resolves to right now.
                The pass runs INLINE and is bounded, so completed false is an ordinary
                outcome on a large audience and means "call again or wait" rather than
                "something failed"; the status field says which of the three reasons
                applies.
                USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key returns the
                stored response without re-running anything, so a retry after a timeout
                would report progress that did not happen. Nobody is double-mailed either
                way: the message spine is insert-wins idempotent per recipient.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      campaign_id:
                        type: string
                      completed:
                        type: boolean
                      enqueued:
                        type: integer
                      paged:
                        type: integer
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/stats:
    get:
      summary: Retrieve marketing campaign stats
      description: >-
        What a send actually did, per A/B arm plus the campaign's totals. A campaign
        with no arms reports one row with an empty variant_key.
        EVERY NUMBER IS DERIVED LIVE from the messages the campaign created rather than
        from a stored counter, so it cannot drift when a send is retried, a provider
        reports late, or a webhook is replayed.
        skipped AND blocked MEAN DIFFERENT THINGS AND ONLY ONE IS ACTIONABLE. skipped is
        the platform correctly declining on the CONTACT's behalf: no consent, a
        frequency cap, a suppression, or a channel with no transport, none of which the
        merchant can change for that person. blocked is the store's own delivery switch
        being off, which the merchant can go and turn back on. Surface them separately
        or the one fixable misconfiguration disappears into a bucket nobody reads.
        REVENUE IS A LIST KEYED BY CURRENCY, never a single number. A store selling into
        several regions can have orders in different currencies attributed to one
        campaign, and adding those together produces a figure that is not money in any
        of them. An empty list means nothing has been attributed yet, which is not the
        same as zero.
        IT NAMES NOBODY. These are counts over an audience, not the people in it;
        resolving who was mailed costs read_marketing_contacts and a different family.
      operationId: get_marketing_campaigns_by_campaignId_stats
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      campaign_id:
                        type: string
                      totals:
                        type: object
                        properties:
                          blocked:
                            type: integer
                          bounced:
                            type: integer
                          clicked:
                            type: integer
                          complained:
                            type: integer
                          converted:
                            type: integer
                          delivered:
                            type: integer
                          enqueued:
                            type: integer
                          failed:
                            type: integer
                          opened:
                            type: integer
                          revenue:
                            type: array
                            items:
                              type: object
                              properties:
                                currency:
                                  type: string
                                minor:
                                  type: integer
                          sent:
                            type: integer
                          skipped:
                            type: integer
                          variant_key:
                            type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            blocked:
                              type: integer
                            bounced:
                              type: integer
                            clicked:
                              type: integer
                            complained:
                              type: integer
                            converted:
                              type: integer
                            delivered:
                              type: integer
                            enqueued:
                              type: integer
                            failed:
                              type: integer
                            opened:
                              type: integer
                            revenue:
                              type: array
                              items:
                                type: object
                                properties:
                                  currency:
                                    type: string
                                  minor:
                                    type: integer
                            sent:
                              type: integer
                            skipped:
                              type: integer
                            variant_key:
                              type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/unschedule:
    post:
      summary: Disarm a scheduled campaign
      operationId: post_marketing_campaigns_by_campaignId_unschedule
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The response is the
                campaign, which returns to draft while KEEPING its scheduled_for: read
                status rather than scheduled_for to decide whether a campaign is armed.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      completed_at:
                        nullable: true
                      created_at:
                        type: string
                      enqueued_count:
                        type: integer
                      id:
                        type: string
                      name:
                        type: string
                      scheduled_for:
                        nullable: true
                      segment_id:
                        type: string
                      started_at:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      template_id:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/variants:
    get:
      summary: List a campaign's variants
      description: >-
        A campaign's A/B arms, in stable key order, which is the order the assignment
        walks.
        A COMPLETE SET RATHER THAN A PAGE. A campaign holds at most 26 arms and this
        list is always scoped to one campaign, so has_more is always false and there is
        no cursor to follow. It carries each arm's body, because a bounded per-campaign
        collection has no size problem and this is the only read of that copy.
        weight is a RELATIVE SHARE rather than a percentage: an arm's odds are its
        weight over the sum of every arm's, so two arms of 1 and 3 split 25/75.
        Assignment is deterministic per contact, so a resumed send never flips somebody
        between arms.
      operationId: get_marketing_campaigns_by_campaignId_variants
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        body:
                          type: string
                        campaign_id:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        subject:
                          type: string
                        updated_at:
                          type: string
                        variant_key:
                          type: string
                        weight:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add an A/B arm to a campaign
      operationId: post_marketing_campaigns_by_campaignId_variants
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_key]
              additionalProperties: false
              description: >-
                Arms may only be changed while the campaign's content is mutable (draft
                or scheduled); anything else is 409 invalid_transition. Adding one
                mid-send would reassign the recipients not yet reached and split one
                campaign's audience across two experiments.
              properties:
                variant_key:
                  type: string
                  description: >-
                    The arm's stable label ('a', 'b', 'control'), at most 32 bytes and
                    unique per campaign. It is what the stats readout groups by and what
                    each message carries, so it is the join key between an arm and its
                    results.
                subject:
                  type: string
                body:
                  type: string
                weight:
                  type: integer
                  description: >-
                    A RELATIVE SHARE, not a percentage: an arm's odds are its weight over
                    the sum of every arm's, so two arms of 1 and 3 split 25/75. Omit the
                    key for an even split. An explicit 0 is 400: the server reads a zero
                    as "no preference" and stores 1, so sending it to disable an arm does
                    the opposite of what you asked.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      campaign_id:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      subject:
                        type: string
                      updated_at:
                        type: string
                      variant_key:
                        type: string
                      weight:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-campaigns/{campaignId}/variants/{variantId}:
    delete:
      summary: Remove an A/B arm
      operationId: delete_marketing_campaigns_by_campaignId_variants_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns:delete
      x-required-scopes: [write_marketing_campaigns, write_marketing_campaigns:delete]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_marketing_campaigns and then on write_marketing_campaigns:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_marketing_campaigns, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Edit an A/B arm
      operationId: patch_marketing_campaigns_by_campaignId_variants_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value. That
                is why this is a PATCH: a full replace that only adjusted a weight would
                blank the arm's subject and body, and the share of the audience assigned
                to it would receive an empty message while the other arms sent correctly.
              properties:
                variant_key:
                  type: string
                  description: Still unique per campaign; a clash is 409 variant_key_taken.
                subject:
                  type: string
                body:
                  type: string
                weight:
                  type: integer
                  description: At least 1. See the create for why an explicit 0 is refused.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      campaign_id:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      subject:
                        type: string
                      updated_at:
                        type: string
                      variant_key:
                        type: string
                      weight:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts:
    get:
      summary: List marketing contacts
      description: >-
        The store's marketing contacts: one row per resolvable person, with the
        per-channel consent state that decides whether they may be mailed.
        THE FILTERS ARE WHAT THIS ROUTE IS FOR. ?email_consent=subscribed is the set
        you may mail today, which is the question an ESP reconciliation actually asks;
        ?q= is a substring match over email, phone and both names for a support
        lookup. Combine them freely.
        FOUR CONSENT STATES, AND never IS NOT unsubscribed. never means this person
        was never asked, unsubscribed means they were and said no, and pending is a
        double opt-in nobody confirmed. Only subscribed is permission to send;
        collapsing the other three into one is how a platform mails someone who
        refused.
        ERASED CONTACTS STAY IN THE LIST rather than disappearing, with erased true
        and every personal field null. That is deliberate: if the row vanished, every
        system syncing from this one would keep the copy this platform just destroyed.
        Treat erased true as a delete on your side.
        TOTAL SPEND IS NOT PUBLISHED. The stored figure sums minor units across
        whatever currency each order was placed in and this record carries no currency
        code, so it is a number you could not interpret. Segment on spend through the
        segments family instead, where the comparison is evaluated server-side.
      operationId: get_marketing_contacts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_contacts
      x-required-scopes: [read_marketing_contacts]
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
          description: >-
            Case-insensitive substring match over email, phone_e164, first_name and
            last_name. % and _ match themselves rather than acting as wildcards, so a
            search for a literal underscore in a local part finds it and a search for
            "%" is not an unfiltered export.
        - name: email_consent
          in: query
          required: false
          schema: { type: string, enum: [subscribed, pending, unsubscribed, never] }
          description: >-
            Filter by the CURRENT email consent state. subscribed is the set you may
            mail; the other three are not, and they are separate facts rather than
            shades of the same one. An unrecognised value is a 400, because a client
            told "no contacts" by a typo concludes the store has an empty list.
        - name: sms_consent
          in: query
          required: false
          schema: { type: string, enum: [subscribed, pending, unsubscribed, never] }
          description: >-
            The same filter for sms. Consent is per channel, so an email unsubscribe
            leaves this untouched.
        - name: push_consent
          in: query
          required: false
          schema: { type: string, enum: [subscribed, pending, unsubscribed, never] }
          description: >-
            The same filter for push.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        anonymous_id:
                          nullable: true
                        created_at:
                          type: string
                        customer_id:
                          nullable: true
                        email:
                          type: [string, "null"]
                        email_consent:
                          type: string
                        erased:
                          type: boolean
                        erasure_scheduled_for:
                          type: [string, "null"]
                        first_name:
                          type: [string, "null"]
                        id:
                          type: string
                        last_name:
                          type: [string, "null"]
                        orders_count:
                          type: integer
                        phone_e164:
                          nullable: true
                        push_consent:
                          type: string
                        resolved_tz:
                          nullable: true
                        sms_consent:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts/resolve:
    post:
      summary: Resolve or create a marketing contact
      description: >-
        Resolves the one contact a set of identity keys names, CREATING it when none
        matches. This is how a person enters the marketing spine at all, so it is the
        first call in any list migration or CDP sync.
        IT IS A WRITE DESPITE READING LIKE A LOOKUP, and it costs
        write_marketing_contacts for that reason.
        It does NOT record consent. A contact created here starts at never on all three
        channels, which is not permission to mail; POST
        /api/v1/marketing-contacts/{marketingContactId}/consent is the second call, and
        the one that carries the disclosure the person agreed to.
      operationId: post_marketing_contacts_resolve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_contacts
      x-required-scopes: [write_marketing_contacts]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                THIS IS THE CREATE. There is no separate one. The keys are searched
                strongest first (customer_id, then email, then phone_e164, then
                anonymous_id) and the first match wins; when nothing matches, a contact
                is CREATED. A pipeline pushing a malformed identity therefore does not
                error, it manufactures a person, so send keys you have verified.
                AT LEAST ONE KEY IS REQUIRED. first_name and last_name name nobody, so a
                body carrying only those is 400 no_identity.
                IDENTITY KEYS ARE WRITE-ONCE. A resolve that MATCHES an existing contact
                fills in a missing first or last name and changes nothing else: it will
                not attach an email to a contact keyed by a customer id and it will not
                re-point a key that is already set. That is the control that stops one
                caller merging two people's profiles or moving a subscribed address onto
                a row it does not own; correcting a key is a dashboard job, where a human
                can see both records.
                200 ON EVERY CALL, never 201. Whether this matched or created is not
                something the response can honestly report: two concurrent resolves of
                the same address race, one inserts and the other re-selects, and both did
                the same thing from your side. Idempotent by identity is the property to
                depend on.
              properties:
                customer_id:
                  type: string
                  description: >-
                    The storefront customer this person signs in as: the strongest key,
                    searched first. Unique per store.
                anonymous_id:
                  type: string
                  description: >-
                    A browser identifier for someone not yet known by name: the weakest
                    key, searched last. Unique per store.
                email:
                  type: string
                  description: >-
                    Stored lower-cased and unique per store, so casing never splits one
                    person into two contacts. Must be a single valid address.
                phone_e164:
                  type: string
                  description: >-
                    E.164 with a LEADING + and a country code, for example +14155550123.
                    A national number is refused rather than guessed at a country:
                    guessing wrong routes a message to a different person.
                first_name:
                  type: string
                  description: >-
                    Filled in on a match only where it is currently unset; an existing
                    name is never overwritten.
                last_name:
                  type: string
                  description: >-
                    Same rule as first_name.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      anonymous_id:
                        nullable: true
                      created_at:
                        type: string
                      customer_id:
                        nullable: true
                      email:
                        type: [string, "null"]
                      email_consent:
                        type: string
                      erased:
                        type: boolean
                      erasure_scheduled_for:
                        nullable: true
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      orders_count:
                        type: integer
                      phone_e164:
                        nullable: true
                      push_consent:
                        type: string
                      resolved_tz:
                        nullable: true
                      sms_consent:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts/{marketingContactId}:
    get:
      summary: Retrieve a marketing contact
      description: >-
        One contact by id. Same shape as the list.
        erasure_scheduled_for is the timestamp a pending erasure will wipe this
        contact, and null when none is scheduled. It MOVES while an erasure is being
        processed, because the worker extends its own lease each time it picks the row
        up, so read it as "not before this time" rather than as an appointment.
      operationId: get_marketing_contacts_by_marketingContactId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_contacts
      x-required-scopes: [read_marketing_contacts]
      parameters:
        - name: marketingContactId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      anonymous_id:
                        nullable: true
                      created_at:
                        type: string
                      customer_id:
                        nullable: true
                      email:
                        type: [string, "null"]
                      email_consent:
                        type: string
                      erased:
                        type: boolean
                      erasure_scheduled_for:
                        type: [string, "null"]
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      orders_count:
                        type: integer
                      phone_e164:
                        nullable: true
                      push_consent:
                        type: string
                      resolved_tz:
                        nullable: true
                      sms_consent:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts/{marketingContactId}/consent:
    post:
      summary: Record a consent change for a contact
      description: >-
        Appends a consent event and moves the contact's state for that channel, in one
        transaction, so the proof and the state can never diverge.
        THE LEDGER CANNOT BE EDITED. Nothing on this API or in the dashboard can
        rewrite or remove a row, by design: that immutability is what makes it worth
        producing to a regulator. Correct a mistake by recording the event that is
        true now.
        A KEY HOLDING write_marketing_contacts CAN RECORD AN OPT-IN, which is the
        direction that costs the merchant rather than the shopper. Ask for this grant
        only where your system genuinely captured the consent.
        The response is the contact, so you can read whether the person may now be
        mailed.
      operationId: post_marketing_contacts_by_marketingContactId_consent
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_contacts
      x-required-scopes: [write_marketing_contacts]
      parameters:
        - name: marketingContactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, action]
              additionalProperties: false
              description: >-
                THIS WRITES EVIDENCE, NOT A SETTING. The row lands in an append-only
                ledger this platform holds no update or delete permission on, so a wrong
                record stands forever and can only be contradicted by a later one, and
                that ledger is what a merchant produces to show a subscribe was real.
                Send the action that is true.
                THE ACTION DECIDES THE STATE, which is why there is no consent field to
                write: subscribe, confirm and import all land on subscribed, unsubscribe
                lands on unsubscribed, and pending lands on pending. An event and the
                state it produced can never disagree.
                THE BODY IS TWO FIELDS AND THE SERVER OWNS THE REST OF THE ROW. source,
                text_shown, ip, ua and occurred_at are each refused BY NAME with a 400,
                not ignored, because all five describe the person who acted and this
                request was made by your server rather than by them. Your address and
                http client describe your integration; the disclosure text describes a
                page an api write showed nobody. The server stamps source as public_api
                and text_shown as its own sentence saying so, and that is the whole
                point of the ledger: an auditor has to be able to separate a row a
                recipient produced by clicking a link in their own inbox from a row a
                merchant's integration wrote on their behalf, because only the first is
                evidence the recipient consented. Consent captured from a real shopper
                on the storefront keeps its own source, text, address and device.
                The response is the CONTACT, so you can read the resulting state, not the
                event you just sent.
              properties:
                channel:
                  type: string
                  enum: [email, sms, push]
                  description: >-
                    Which channel this decision is about. Consent is per channel: an
                    email unsubscribe says nothing about sms.
                action:
                  type: string
                  enum: [subscribe, unsubscribe, confirm, pending, import]
                  description: >-
                    What happened. confirm is the completion of a double opt-in, so send
                    it only when you hold the evidence the person clicked a link in their
                    own inbox; import is the right action when migrating a list from
                    another system, and it records the subscription as inherited rather
                    than freshly given. There is no default, because every value here
                    changes who may be mailed.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      anonymous_id:
                        nullable: true
                      created_at:
                        type: string
                      customer_id:
                        nullable: true
                      email:
                        type: [string, "null"]
                      email_consent:
                        type: string
                      erased:
                        type: boolean
                      erasure_scheduled_for:
                        nullable: true
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      orders_count:
                        type: integer
                      phone_e164:
                        nullable: true
                      push_consent:
                        type: string
                      resolved_tz:
                        nullable: true
                      sms_consent:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts/{marketingContactId}/consent-events:
    get:
      summary: List a marketing contact's consent events
      description: >-
        One contact's consent ledger, newest first: how the state on the contact came
        to be what it is.
        THIS IS THE EVIDENCE. The consent fields on a contact say what is true now;
        these rows say which channel, which decision, which words the person was shown
        and when. It is append-only and this platform holds no permission to update or
        delete a row, so a mistake can only be contradicted by a later event, never
        rewritten.
        ?channel= narrows it to email, sms or push. Omit it for the whole trail, which
        is the call to make when reconciling one person across channels.
        NO IP AND NO USER AGENT are published. They are a real person's network address
        and device, and nothing that makes this row proof depends on them.
      operationId: get_marketing_contacts_by_marketingContactId_consent_events
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_contacts
      x-required-scopes: [read_marketing_contacts]
      parameters:
        - name: marketingContactId
          in: path
          required: true
          schema: { type: string }
        - name: channel
          in: query
          required: false
          schema: { type: string, enum: [email, sms, push] }
          description: >-
            Narrow the trail to one channel. OMIT IT for the whole history, which is
            the call to make when reconciling one person across channels: the
            dashboard's own read requires a channel because it drives a per-channel
            tab, and requiring one here would make the common case cost three
            requests. An unrecognised value is a 400.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        action:
                          type: string
                        channel:
                          type: string
                        contact_id:
                          type: string
                        id:
                          type: string
                        occurred_at:
                          type: string
                        source:
                          type: [string, "null"]
                        text_shown:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-contacts/{marketingContactId}/erasure/cancel:
    post:
      summary: Cancel a scheduled erasure for a contact
      description: >-
        Calls off a scheduled erasure, so a contact who re-engaged inside the cooldown
        keeps their data.
        THE OTHER HALF IS NOT ON THIS API. Scheduling an erasure destroys a named
        person's profile with no restore and no announcement, so there is no
        write_marketing_contacts:delete grant to ask for and no route that could use
        one; it stays in the merchant's dashboard. Cancelling is the recovery
        operation and rides the plain write scope, because undoing destruction must
        not require the destructive grant.
        Read erasure_scheduled_for on the contact to know whether there is anything to
        cancel.
      operationId: post_marketing_contacts_by_marketingContactId_erasure_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_contacts
      x-required-scopes: [write_marketing_contacts]
      parameters:
        - name: marketingContactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                SCHEDULING an erasure is not part of this API and never will be: it
                destroys a named person's profile with no restore, so it stays in the
                merchant's dashboard where a human owns the decision. CALLING ONE OFF is
                the opposite of destruction, so it rides write_marketing_contacts
                unqualified. It is a POST rather than a DELETE for the same reason: a
                DELETE under a contact would read as "erase this contact".
                200 WITH cancelled=false WHEN NOTHING WAS SCHEDULED, not a 404. The
                contact exists and the state you asked for is the state that holds; the
                flag is there so you can tell "called off" from "there never was one".
                An unknown contact id is still a 404.
                USE A FRESH Idempotency-Key. A replayed key returns the stored response
                without re-running anything, so a second cancel on the same key answers
                with the first call's result and calls nothing off.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flow-runs:
    get:
      summary: List marketing flow runs
      description: >-
        One row per contact per journey: who is in a program, which step they are on, and
        when they next move.
        ?flow_id= answers "who is in this program" and ?contact_id= answers "which programs
        is this person in". The second is the reconciliation an integration actually
        performs on a customer record, and no per-flow route could answer it without walking
        every flow in the store, which is why runs are a top-level collection here rather
        than nested under a flow.
        ?status= takes active, waiting, completed, exited, errored or cancelled. waiting is
        the durable-timer state a long program spends most of its life in; next_run_at says
        when it moves, and is a floor rather than an appointment.
        THE SCHEDULER'S OWN BOOKKEEPING IS NOT PUBLISHED. A run carries no lease, no attempt
        count and no trigger event id: the first two are this platform's crash-retry
        internals and the third belongs to the events family.
      operationId: get_marketing_flow_runs
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: flow_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow to one program: who is in it and where they are. An id that names no
            flow in this store returns an empty page rather than a 404, because this is one
            optional filter among three and a caller reconciling by contact_id has no flow
            to verify.
        - name: contact_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow to one person: which programs they are in across the whole store. This
            is the reconciliation a customer-record integration performs, and it is why runs
            are a top-level collection rather than nested under a flow.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, waiting, completed, exited, errored, cancelled] }
          description: >-
            Narrow to one run state. There is no claimed value here even though the engine
            has one internally: a leased run publishes as active, so accepting claimed would
            return rows whose published status is a different word from the one asked for.
            An unrecognised value is a 400.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        contact_id:
                          type: string
                        current_step_key:
                          type: [string, "null"]
                        enrolled_at:
                          type: string
                        exit_reason:
                          type: [string, "null"]
                        finished_at:
                          type: [string, "null"]
                        flow_id:
                          type: string
                        flow_version_id:
                          type: string
                        id:
                          type: string
                        next_run_at:
                          type: string
                        status:
                          type: string
                        steps_executed:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flow-runs/{marketingFlowRunId}:
    get:
      summary: Retrieve a marketing flow run
      description: >-
        One contact's journey. It says where they are NOW; what already happened to them is
        the steps collection, which is a separate route because a trail has no bound a
        contract can rely on.
      operationId: get_marketing_flow_runs_by_marketingFlowRunId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: marketingFlowRunId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contact_id:
                        type: string
                      current_step_key:
                        type: [string, "null"]
                      enrolled_at:
                        type: string
                      exit_reason:
                        type: [string, "null"]
                      finished_at:
                        type: [string, "null"]
                      flow_id:
                        type: string
                      flow_version_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      status:
                        type: string
                      steps_executed:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flow-runs/{marketingFlowRunId}/steps:
    get:
      summary: List a marketing flow run's steps
      description: >-
        A run's append-only audit trail, OLDEST FIRST: this run executed this step, and this
        is what happened. It is the only list on this API that reads forwards, because it is
        a story in order.
        THIS IS WHAT MAKES A FLOW DEBUGGABLE. Without it, "the customer never got the email"
        and "we never sent it" are the same observation from outside.
        outcome=enqueued means the send was HANDED TO THE MESSAGE SPINE, not that it was
        delivered: the spine owns dedupe, the compliance gate, dispatch and retries, and can
        still refuse a send recorded here. message_id is set on a send step and is what you
        join against the notifications family to see the verdict.
      operationId: get_marketing_flow_runs_by_marketingFlowRunId_steps
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: marketingFlowRunId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        executed_at:
                          type: string
                        flow_run_id:
                          type: string
                        id:
                          type: string
                        message_id:
                          nullable: true
                        outcome:
                          type: string
                        step_key:
                          type: string
                        step_type:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flow-settings:
    get:
      summary: Retrieve marketing flow settings
      description: >-
        The store's flow sending policy. A store that has never saved one reads the DEFAULTS
        rather than a 404, because those defaults really are what the engine is running
        under; updated_at is NULL in that state, and that null is what the first PUT must
        send back as expected_updated_at.
        THE TWO HOURS ARE A SEND WINDOW, NOT A QUIET PERIOD. Sending is PERMITTED while the
        recipient's local hour is in [send_window_start_hour, send_window_end_hour) and
        deferred otherwise, so the policy "quiet hours 21:00 to 09:00" is the window [9,
        21), which is the default. Reading it backwards is the single likeliest way to mail
        a merchant's whole list at 3am.
        updated_at is ALSO THE VERSION TOKEN. Send it back as expected_updated_at on the
        PUT, or null if this store has never saved, and your write refuses rather than
        reverting a change you never saw.
      operationId: get_marketing_flow_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enrollment_dedupe_enabled:
                        type: boolean
                      max_runs_per_contact_per_day:
                        type: integer
                      max_steps_per_claim:
                        type: integer
                      respect_quiet_hours:
                        type: boolean
                      send_window_end_hour:
                        type: integer
                      send_window_start_hour:
                        type: integer
                      unknown_tz_mode:
                        type: string
                      updated_at:
                        type: [string, "null"]
                      wake_jitter_seconds:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace the store's flow sending policy
      operationId: put_marketing_flow_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [send_window_start_hour, send_window_end_hour, respect_quiet_hours, enrollment_dedupe_enabled, unknown_tz_mode, wake_jitter_seconds, max_steps_per_claim, max_runs_per_contact_per_day, expected_updated_at]
              additionalProperties: false
              description: >-
                An UPSERT behind a PUT: a store with no policy row gets one, a store with
                one has it replaced whole.
                EVERY FIELD IS REQUIRED. A dropped respect_quiet_hours would decode to
                false and switch the store's send window off entirely, which on a store
                with SMS steps means mailing people in the middle of their night, and it
                would answer 200. Read GET /api/v1/marketing-flow-settings and send every
                field back.
                THE TWO HOURS ARE A SEND WINDOW, NOT A QUIET PERIOD, and reading them
                backwards is the single likeliest way to blast a list at 3am. Sending is
                PERMITTED while the recipient's local hour is in [start, end) and deferred
                otherwise, so the policy "quiet hours 21:00 to 09:00" is the window [9,
                21), which is the default. quiet_start_hour and quiet_end_hour are refused
                by name.
                expected_updated_at IS REQUIRED and is the other half. This row has several
                writers, so an integration echoing back what it read ten seconds ago would
                silently revert the change the merchant just made in their dashboard, with
                a 200 on both calls. A mismatch is 409 settings_modified: re-read and send
                the new updated_at. NULL is legal and means "I expect this store to have no
                settings row yet"; it is refused the moment a row exists, so it cannot be
                used to opt out of the check. An ABSENT key is 400, because absence asks
                for the blind overwrite this exists to prevent.
              properties:
                send_window_start_hour:
                  type: integer
                  description: Local hour sending becomes permitted, 0 to 23.
                send_window_end_hour:
                  type: integer
                  description: Local hour sending stops, 0 to 23, exclusive.
                respect_quiet_hours:
                  type: boolean
                  description: >-
                    The master switch for the window. False sends whenever a step comes
                    due. The message spine keeps its own statutory floor for marketing
                    SMS regardless, so turning this off widens email rather than defeating
                    that.
                enrollment_dedupe_enabled:
                  type: boolean
                  description: >-
                    Applies the per-flow re-enrollment policy. With it off a contact may
                    re-enter as soon as their previous run finished. The
                    one-live-run-per-contact guard is a database index and holds either
                    way.
                unknown_tz_mode:
                  type: string
                  enum: [us_safe, store_tz]
                  description: >-
                    What happens to a contact whose time zone cannot be resolved. NEITHER
                    VALUE EVER SENDS IMMEDIATELY: us_safe clamps to a window that is
                    daytime across the continental US, store_tz uses the store's own zone,
                    and an unresolvable zone always defers.
                wake_jitter_seconds:
                  type: integer
                  description: >-
                    Spreads a cohort that all finished the same wait at the same instant.
                    Applied forward-only and BEFORE the send-window clamp, so jitter can
                    never push a send back out of the window it was just moved into.
                max_steps_per_claim:
                  type: integer
                  description: >-
                    How far one wake may walk a run before yielding, 1 to 500.
                max_runs_per_contact_per_day:
                  type: integer
                  description: >-
                    An ENROLLMENT ceiling, which is a different thing from a send ceiling:
                    per-contact send frequency belongs to the message spine's gate, and
                    counting it here as well would double-count and drift out of agreement
                    with it. 0 means unlimited.
                expected_updated_at:
                  type: [string, "null"]
                  format: date-time
                  description: >-
                    The updated_at you last read. Send null only when you expect this
                    store to have no settings row yet.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enrollment_dedupe_enabled:
                        type: boolean
                      max_runs_per_contact_per_day:
                        type: integer
                      max_steps_per_claim:
                        type: integer
                      respect_quiet_hours:
                        type: boolean
                      send_window_end_hour:
                        type: integer
                      send_window_start_hour:
                        type: integer
                      unknown_tz_mode:
                        type: string
                      updated_at:
                        type: string
                      wake_jitter_seconds:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows:
    get:
      summary: List marketing flows
      description: >-
        The store's automation flows: a trigger, an enrollment policy, and a pointer at the
        step graph new enrollments are pinned to.
        A FLOW IS A STANDING PROGRAM, not a send. Where a campaign mails a segment once, a
        live flow keeps enrolling and mailing people for as long as it is live, so
        ?status=live is the set that is actually reaching a merchant's list right now.
        ?trigger_type= narrows to event, segment_entry or manual.
        live_version_id is null until the flow has been published at least once. Resolve it
        against GET /api/v1/marketing-flows/{marketingFlowId}/versions; a run carries its
        own pinned version id, which is often an older one.
      operationId: get_marketing_flows
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, live, paused, archived] }
          description: >-
            Narrow to one lifecycle state. live is the set that is actually enrolling and
            mailing people right now, which is the question worth asking first. An
            unrecognised value is a 400 rather than an empty page: "no flows are live" and
            "you spelled live wrong" would otherwise be the same answer.
        - name: trigger_type
          in: query
          required: false
          schema: { type: string, enum: [event, segment_entry, manual] }
          description: >-
            Narrow to one kind of trigger. An unrecognised value is a 400, for the same
            reason.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Flows touched at or after this RFC3339 instant. live_version_id IS THE FIELD THAT
            MOVES, and it decides which version of an automation actually runs against
            shoppers: publishing a new version rewrites the parent without creating one, so a
            cached flow leaves you believing an older version is live. Supplying it CHANGES
            THE ORDER of this endpoint: the default walk descends created_at, and this one
            climbs updated_at, which is the only ordering in which a keyset walk over a
            mutable column cannot skip a row. A flow edited mid-walk moves forward past your
            position and is returned again, so the walk is AT LEAST ONCE. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant. A cursor belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        exit_on_event_names:
                          type: array
                          items:
                            type: string
                        id:
                          type: string
                        live_version_id:
                          type: [string, "null"]
                        name:
                          type: string
                        reenroll_cooldown_secs:
                          type: integer
                        reenrollment:
                          type: string
                        status:
                          type: string
                        trigger_event_name:
                          type: [string, "null"]
                        trigger_segment_id:
                          nullable: true
                        trigger_type:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create an automation flow
      operationId: post_marketing_flows
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, trigger_type]
              additionalProperties: false
              description: >-
                A flow ALWAYS lands as a draft with no published graph, so this route can
                never mail anybody: publishing a version and taking it live are two
                further explicitly named calls. status and live_version_id are refused by
                name for that reason.
                THE STEP GRAPH IS NOT ON THIS BODY. It lives in an immutable version, so
                changing the program means POSTing to .../versions; this body carries only
                who enters the flow and under what policy.
              properties:
                name:
                  type: string
                  description: >-
                    What the merchant sees in their own dashboard. Unique per store, so a
                    repeat is 409 name_taken rather than a second program nobody can tell
                    apart from the first. At most 200 bytes.
                trigger_type:
                  type: string
                  enum: [event, segment_entry, manual]
                  description: >-
                    What admits a contact. event enrolls whoever performs a named
                    behaviour, segment_entry enrolls whoever joins a segment, and manual
                    enrolls only who is explicitly pushed through .../enroll. It has no
                    default and cannot be inferred.
                trigger_event_name:
                  type: string
                  description: >-
                    REQUIRED when trigger_type is event. A trigger that does not carry the
                    thing it triggers on could never fire, so it is 400 invalid_flow rather
                    than a flow that silently enrolls nobody.
                trigger_segment_id:
                  type: string
                  description: REQUIRED when trigger_type is segment_entry, for the same reason.
                reenrollment:
                  type: string
                  enum: [never, after_exit, after_cooldown]
                  description: >-
                    Whether someone who has been through this program may enter it again.
                    An omitted key stores never, which is the conservative one: it is the
                    difference between a welcome series and a loop that mails the same
                    person every week.
                reenroll_cooldown_secs:
                  type: integer
                  description: >-
                    How long after_cooldown waits before a contact is eligible again.
                    REQUIRED and non-zero when reenrollment is after_cooldown; meaningless
                    on the other two.
                exit_on_event_names:
                  type: array
                  items:
                    type: string
                  description: >-
                    Behavioural events that end a run early, so a contact who converts
                    stops receiving the rest of the sequence. Empty means a run only ends
                    by reaching an exit step.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        nullable: true
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}:
    get:
      summary: Retrieve a marketing flow
      description: >-
        One flow's trigger and enrollment policy. The step graph is NOT here: it lives in an
        immutable version, so read the versions collection for the program itself.
      operationId: get_marketing_flows_by_marketingFlowId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        type: [string, "null"]
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Edit a flow's trigger and enrollment policy
      operationId: patch_marketing_flows_by_marketingFlowId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value, which is
                why this is a PATCH. Sending the whole resource would let an omitted
                reenrollment reset a weekly program to never, an omitted
                exit_on_event_names remove every early exit, and an omitted
                trigger_event_name leave an event trigger that can never fire, all with a
                200 and nothing visible until the merchant notices their list stopped
                receiving mail.
                EDITING A LIVE FLOW IS ALLOWED and changes only who enters it NEXT. The
                step graph is untouched, because it lives in an immutable version, and
                every run in flight keeps walking the version it enrolled on.
                exit_on_event_names is a WHOLE-LIST REPLACE when present, because a bare
                string carries no identity to patch an element of. Send [] to clear it;
                omit the key to leave it alone.
              properties:
                name:
                  type: string
                  description: Still unique per store; a clash is 409 name_taken.
                trigger_type:
                  type: string
                  enum: [event, segment_entry, manual]
                trigger_event_name:
                  type: string
                trigger_segment_id:
                  type: string
                reenrollment:
                  type: string
                  enum: [never, after_exit, after_cooldown]
                reenroll_cooldown_secs:
                  type: integer
                exit_on_event_names:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        type: [string, "null"]
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}/activate:
    post:
      summary: Take a flow live so it starts enrolling and sending
      operationId: post_marketing_flows_by_marketingFlowId_activate
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and a body is REFUSED rather than ignored so a client
                posting {"status":"archived"} here cannot read the 200 as agreement.
                THIS IS THE CALL THAT STARTS MAILING PEOPLE. Everything before it is
                configuration; this is what lets the trigger matcher find the flow and
                begin enrolling real contacts into a program that sends. A flow with no
                published version is 409 no_published_version rather than a live flow
                enrolling contacts into nothing.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        type: [string, "null"]
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}/archive:
    post:
      summary: Retire a flow and cancel every run in flight
      operationId: post_marketing_flows_by_marketingFlowId_archive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows:delete
      x-required-scopes: [write_marketing_flows, write_marketing_flows:delete]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and a body is refused rather than ignored.
                IRREVERSIBLE AND WIDER THAN IT LOOKS. There is no transition out of
                archived, and the same transaction CANCELS EVERY RUN IN FLIGHT, so a
                merchant with a large cohort mid-journey loses all of them at once and no
                later call brings them back. Those contacts do not resume.
                That is why it needs write_marketing_flows:delete on top of
                write_marketing_flows, even though it is a POST and destroys no row: the
                qualifier names an irreversible OPERATION rather than the DELETE verb. A
                key that manages a merchant's programs day to day should hold the write
                half only, and will get 403 here.
                Use pause if you want the flow to stop and the people already in it to
                finish.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        type: [string, "null"]
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_marketing_flows and then on write_marketing_flows:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_marketing_flows, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}/enroll:
    post:
      summary: Enroll one contact into a flow
      operationId: post_marketing_flows_by_marketingFlowId_enroll
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              additionalProperties: false
              description: >-
                Admits one contact and answers with the RUN that was created, which is
                what you poll afterwards.
                IT TAKES THE SAME PATH THE TRIGGERS TAKE, so the flow's re-enrollment
                policy, the store's daily enrollment ceiling and the
                one-live-run-per-contact guard all apply. This is not a way around a
                policy the merchant set: an ineligible contact is 409 not_eligible, and a
                flow that is not live is 409 invalid_transition.
                ENROLLING A WHOLE SEGMENT IS NOT PUBLISHED and segment_id is refused by
                name. One call quietly enrolling an unbounded audience into a sending
                program is the largest blast radius on this API behind a body with no
                confirmation step, and the underlying operation answers 202 with a queued
                count, which an idempotency replay would report as a completion that may
                since have failed. Give the flow a segment_entry trigger instead.
                trigger_event_id is refused too: it records the behavioural event that
                CAUSED an enrollment, so a caller-supplied one would let a key write a run
                indistinguishable from one a real customer's own behaviour produced. This
                enrollment carries no event, which is the honest record of what happened.
              properties:
                contact_id:
                  type: string
                  description: >-
                    The marketing contact to admit. It must already exist in this store;
                    resolve or create one through
                    POST /api/v1/marketing-contacts/resolve, which needs
                    write_marketing_contacts.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contact_id:
                        type: string
                      current_step_key:
                        nullable: true
                      enrolled_at:
                        type: string
                      exit_reason:
                        nullable: true
                      finished_at:
                        nullable: true
                      flow_id:
                        type: string
                      flow_version_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      status:
                        type: string
                      steps_executed:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}/pause:
    post:
      summary: Stop new enrollments without ending runs in flight
      operationId: post_marketing_flows_by_marketingFlowId_pause
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and a body is refused rather than ignored.
                REVERSIBLE, and it is the operation to reach for. It stops new enrollments
                and leaves every run in flight exactly where it is, so activating again
                resumes the program rather than restarting it. Archive is the
                irreversible sibling and takes a second grant.
                Note that publishing a version to a paused flow RESUMES it. If an
                integration pushes an edit to a program a merchant paused, the flow ends
                up live.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      exit_on_event_names:
                        type: array
                        items:
                          type: string
                      id:
                        type: string
                      live_version_id:
                        type: [string, "null"]
                      name:
                        type: string
                      reenroll_cooldown_secs:
                        type: integer
                      reenrollment:
                        type: string
                      status:
                        type: string
                      trigger_event_name:
                        type: [string, "null"]
                      trigger_segment_id:
                        nullable: true
                      trigger_type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-flows/{marketingFlowId}/versions:
    get:
      summary: List a marketing flow's versions
      description: >-
        A flow's published step graphs, newest first. Each is frozen: publishing an edit
        appends a new version rather than rewriting an old one, and this platform holds no
        permission to update the row.
        THAT IS WHY THIS COLLECTION MATTERS. A contact three days into a five-day program
        keeps walking the version they enrolled on, so a run's flow_version_id and the
        flow's live_version_id routinely disagree, and only this route lets you see what
        each of them is actually running.
        The definition is returned in the SAME shape a publish accepts, so a client can read
        a version, change one step and post the result back without a second mapping.
      operationId: get_marketing_flows_by_marketingFlowId_versions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_flows
      x-required-scopes: [read_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        definition:
                          type: object
                          properties:
                            entry_step:
                              type: string
                            schema_version:
                              type: integer
                            steps:
                              type: object
                              properties:
                                done:
                                  type: object
                                  properties:
                                    reason:
                                      type: string
                                    type:
                                      type: string
                                finished:
                                  type: object
                                  properties:
                                    reason:
                                      type: string
                                    type:
                                      type: string
                                hold:
                                  type: object
                                  properties:
                                    duration_seconds:
                                      type: integer
                                    next:
                                      type: string
                                    type:
                                      type: string
                                nudge:
                                  type: object
                                  properties:
                                    channel:
                                      type: string
                                    next:
                                      type: string
                                    template_id:
                                      type: string
                                    type:
                                      type: string
                                opened:
                                  type: object
                                  properties:
                                    condition:
                                      type: object
                                      properties:
                                        kind:
                                          type: string
                                        min_count:
                                          type: integer
                                        name:
                                          type: string
                                        within_seconds:
                                          type: integer
                                    else:
                                      type: string
                                    then:
                                      type: string
                                    type:
                                      type: string
                                welcome:
                                  type: object
                                  properties:
                                    channel:
                                      type: string
                                    next:
                                      type: string
                                    template_id:
                                      type: string
                                    type:
                                      type: string
                        flow_id:
                          type: string
                        id:
                          type: string
                        published_at:
                          type: string
                        version:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Publish a step graph as a new version
      operationId: post_marketing_flows_by_marketingFlowId_versions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_flows
      x-required-scopes: [write_marketing_flows]
      parameters:
        - name: marketingFlowId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition]
              additionalProperties: false
              description: >-
                Freezes a step graph and points the flow at it, in one transaction under
                the flow's row lock. The version row is physically immutable, so an edit
                appends version N+1 rather than rewriting N.
                RUNS IN FLIGHT ARE NOT AFFECTED. Every run resolves its graph through its
                own pinned flow_version_id, so a contact three days into a five-day
                program finishes the program they started rather than being teleported
                into a step that did not exist when they enrolled.
                THIS TAKES THE FLOW LIVE, whatever it was. The status is set in the same
                statement that points the flow at the new version, because a live flow
                requires a version and a two-call version of this could leave a live flow
                with no graph. The consequence to know about is that publishing to a
                PAUSED flow RESUMES it: read status back from this response and pause
                again if that was not the intent. An archived flow is 409
                invalid_transition.
                THE VERSION NUMBER IS NOT ON THIS BODY. The server allocates it under the
                flow's row lock; a caller-chosen one would let two publishes claim the
                same number, and runs are pinned to a version by id, so a forked sequence
                is unrecoverable.
                THE GRAPH IS VALIDATED ONCE, HERE: every successor resolves to a real
                step, the graph is acyclic, every step is reachable and at least one exit
                is reachable. A failure is 400 invalid_definition naming the offending
                step, and nothing is stored.
              properties:
                definition:
                  type: object
                  required: [entry_step, steps]
                  additionalProperties: false
                  properties:
                    schema_version:
                      type: integer
                      description: >-
                        The encoding version of this document, NOT the flow version
                        number. The server stamps it; omit it.
                    entry_step:
                      type: string
                      description: >-
                        The key a fresh run starts on. It must name a step in steps.
                    steps:
                      type: object
                      description: >-
                        The graph, at most 200 nodes, keyed by a merchant-chosen string of
                        at most 100 bytes. Keys rather than array indices, so reordering a
                        graph does not scramble the audit history: a step run records the
                        key it executed, and the same key means the same step across
                        versions.
                        A wait step takes duration_seconds and optionally
                        align_to_local_hour, which pins the wake to an hour in the
                        RECIPIENT's local time. A send step takes channel and template_id,
                        and optionally respect_quiet_hours to override the store policy.
                        A branch step takes a condition plus then and else. An exit step
                        takes an optional reason and MUST NOT name a successor. Everything
                        except a branch and an exit takes next.
                        A condition's kind is one of in_segment (segment_id), event_since
                        (name, within_seconds, min_count) or contact_field (field, op,
                        value). The vocabulary is closed: an authored condition is
                        untrusted input, so anything else is refused here rather than
                        evaluated at run time while a contact waits mid-journey.
                      additionalProperties:
                        type: object
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      definition:
                        type: object
                        properties:
                          entry_step:
                            type: string
                          schema_version:
                            type: integer
                          steps:
                            type: object
                            properties:
                              done:
                                type: object
                                properties:
                                  reason:
                                    type: string
                                  type:
                                    type: string
                              finished:
                                type: object
                                properties:
                                  reason:
                                    type: string
                                  type:
                                    type: string
                              hold:
                                type: object
                                properties:
                                  duration_seconds:
                                    type: integer
                                  next:
                                    type: string
                                  type:
                                    type: string
                              nudge:
                                type: object
                                properties:
                                  channel:
                                    type: string
                                  next:
                                    type: string
                                  template_id:
                                    type: string
                                  type:
                                    type: string
                              opened:
                                type: object
                                properties:
                                  condition:
                                    type: object
                                    properties:
                                      kind:
                                        type: string
                                      min_count:
                                        type: integer
                                      name:
                                        type: string
                                      within_seconds:
                                        type: integer
                                  else:
                                    type: string
                                  then:
                                    type: string
                                  type:
                                    type: string
                              welcome:
                                type: object
                                properties:
                                  channel:
                                    type: string
                                  next:
                                    type: string
                                  template_id:
                                    type: string
                                  type:
                                    type: string
                      flow_id:
                        type: string
                      id:
                        type: string
                      published_at:
                        type: string
                      version:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_flows scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms:
    get:
      summary: List marketing forms
      description: >-
        The store's signup forms and pop-ups: where each renders, what it asks, and the
        consent block it shows. ?status=active is the set the storefront is actually
        serving right now.
        honeypot_key IS NOT PUBLISHED on any route in this family. It is the hidden
        field a real shopper never fills and a bot must leave empty, so it is the whole
        of the spam defence; a client that could read it could submit past it on every
        form in the store.
      operationId: get_marketing_forms
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_forms
      x-required-scopes: [read_marketing_forms]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, active, paused] }
          description: >-
            Filter by lifecycle state. Omit it to receive all three. ONLY active forms
            are served to shoppers, so ?status=active is how you ask what the storefront
            is showing right now, and it is the v1 replacement for the storefront's own
            form feed.
            An unrecognised value is a 400 rather than an empty page: a client filtering
            on "live" would otherwise conclude the store runs no signup forms at all.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Forms touched at or after this RFC3339 instant, for reconciling incrementally instead
            of re-reading every form on every run. It composes with status. Supplying it CHANGES
            THE ORDER of this endpoint: the default walk descends created_at, and this one climbs
            updated_at, because that is the only ordering in which a keyset walk over a mutable
            column cannot skip a row. A form changed mid-walk moves forward past your position and
            is returned again, so the walk is AT LEAST ONCE. WHAT A MISS COSTS HERE IS A FORM ON A
            PAGE THE MERCHANT TOOK DOWN: status decides whether the storefront renders the form at
            all, so a client that missed a pause keeps embedding one that was retired and keeps
            collecting addresses through it. A cursor from one ordering is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        consent:
                          type: object
                          properties:
                            email:
                              type: object
                              properties:
                                double_opt_in:
                                  type: boolean
                                mode:
                                  type: string
                                pre_checked:
                                  type: boolean
                                required:
                                  type: boolean
                                text:
                                  type: string
                            sms:
                              type: object
                              properties:
                                double_opt_in:
                                  type: boolean
                                mode:
                                  type: string
                                pre_checked:
                                  type: boolean
                                required:
                                  type: boolean
                                text:
                                  type: string
                        created_at:
                          type: string
                        fields:
                          type: array
                          items:
                            type: object
                            properties:
                              key:
                                type: string
                              label:
                                type: string
                              options:
                                type: array
                                items:
                                  {}
                              placeholder:
                                type: string
                              required:
                                type: boolean
                              type:
                                type: string
                        id:
                          type: string
                        name:
                          type: string
                        status:
                          type: string
                        success:
                          type: object
                          properties:
                            message:
                              type: string
                            redirect_url:
                              type: string
                            reveal_coupon:
                              type: boolean
                        targeting:
                          type: object
                          properties:
                            device:
                              type: string
                            exclude_urls:
                              type: array
                              items:
                                type: object
                                properties:
                                  op:
                                    type: string
                                  value:
                                    type: string
                            frequency:
                              type: object
                              properties:
                                after_dismiss:
                                  type: object
                                  properties:
                                    days:
                                      type: integer
                                    unit:
                                      type: string
                                after_submit:
                                  type: object
                                  properties:
                                    days:
                                      type: integer
                                    unit:
                                      type: string
                            include_urls:
                              type: array
                              items:
                                type: object
                                properties:
                                  op:
                                    type: string
                                  value:
                                    type: string
                            priority:
                              type: integer
                            trigger:
                              type: object
                              properties:
                                delay_seconds:
                                  type: integer
                                idle_seconds:
                                  type: integer
                                kind:
                                  type: string
                                scroll_percent:
                                  type: integer
                        type:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a signup form
      operationId: post_marketing_forms
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_forms
      x-required-scopes: [write_marketing_forms]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type, status, fields, targeting, consent]
              additionalProperties: false
              description: >-
                A FULL DEFINITION on both writes, which is why the create and the replace
                take almost the same body and why the update is PUT rather than PATCH. An
                omitted optional key is CLEARED rather than carried over, so read the form
                and send back what you want it to end up with.
                THE REQUIRED FIELDS INCLUDE consent AND status, which look defaultable and
                are not: an omitted consent decodes to empty modes and the form silently
                stops asking anyone to agree to anything while it keeps collecting
                addresses, and an omitted status decodes to draft and takes a live form off
                the storefront. Neither raises an error and neither is visible until the
                merchant notices their signups stopped.
                type IS REQUIRED HERE AND CANNOT BE CHANGED LATER. The replace refuses the
                key outright rather than ignoring it.
                Names are unique per store and case-insensitive; a duplicate is 409
                name_taken rather than a second form.
              properties:
                type:
                  type: string
                  enum: [embed, popup, flyout, landing]
                  description: >-
                    How the form is presented: inline in the page, a centred modal, a
                    panel sliding in from an edge, or a full page of its own. IMMUTABLE
                    after creation, because the widget renders each differently enough
                    that switching one into another under live traffic is a surprise
                    rather than a feature. Create a second form instead.
                name:
                  type: string
                  maxLength: 120
                  description: >-
                    Unique per store and compared case-insensitively, which makes it a
                    stable handle a migration tool can re-run against. A duplicate is 409
                    name_taken. Merchant-facing only; a shopper never sees it.
                status:
                  type: string
                  enum: [draft, active, paused]
                  description: >-
                    ONLY active is served to shoppers. Setting it here activates or pauses
                    the form as part of the same write; the dedicated activate and pause
                    routes are the way to do it without touching the definition.
                fields:
                  type: array
                  minItems: 1
                  maxItems: 12
                  description: >-
                    The ordered input list. EXACTLY ONE field must have type email: it is
                    the identity a submission keys a contact on, and a form with none has
                    nothing to key while a form with two is an ambiguity on the identity
                    path. An email field is always stored as required whatever you send,
                    because an optional identity is not an identity.
                  items:
                    type: object
                    required: [key, type]
                    additionalProperties: false
                    properties:
                      key:
                        type: string
                        maxLength: 40
                        description: >-
                          Lower-case ascii, digits and underscores, starting with a
                          letter, and unique within the form. It is the key a submission's
                          values are recorded under, so changing it orphans the answers
                          already collected under the old one.
                      type:
                        type: string
                        enum: [email, phone, first_name, last_name, text, number, date, select, checkbox, hidden]
                        description: >-
                          A CLOSED vocabulary with no escape hatch. phone values must be
                          E.164 when submitted, since sms consent is never recorded
                          against a string that is not a dialable number.
                      label:
                        type: string
                        maxLength: 120
                      placeholder:
                        type: string
                        maxLength: 120
                      required:
                        type: boolean
                      options:
                        type: array
                        items: { type: string }
                        maxItems: 20
                        description: >-
                          The choice list for a select field, between 1 and 20 entries.
                          Dropped rather than refused on every other type, so a dashboard
                          leaving a stale list on a field whose type changed does not fail
                          the save.
                targeting:
                  type: object
                  required: [device, trigger]
                  additionalProperties: false
                  description: Where, when and to whom the form shows.
                  properties:
                    include_urls:
                      type: array
                      maxItems: 20
                      items:
                        type: object
                        required: [op, value]
                        additionalProperties: false
                        properties:
                          op:
                            type: string
                            enum: [contains, exact, prefix]
                            description: >-
                              Three operators and deliberately NO regex: merchant-authored
                              regex on a public storefront hot path is a denial-of-service
                              waiting to happen.
                          value:
                            type: string
                            maxLength: 500
                      description: Empty or omitted means every page.
                    exclude_urls:
                      type: array
                      maxItems: 20
                      items:
                        type: object
                        required: [op, value]
                        additionalProperties: false
                        properties:
                          op:
                            type: string
                            enum: [contains, exact, prefix]
                            description: >-
                              Three operators and deliberately NO regex: merchant-authored
                              regex on a public storefront hot path is a denial-of-service
                              waiting to happen.
                          value:
                            type: string
                            maxLength: 500
                      description: Always wins over include_urls.
                    device:
                      type: string
                      enum: [all, desktop, mobile]
                      description: >-
                        Tablets fold into desktop: a tablet has a cursor-like pointer and
                        a desktop-width viewport, so a three-way split would be a
                        configuration decision that changes nothing a shopper sees.
                    trigger:
                      type: object
                      required: [kind]
                      additionalProperties: false
                      properties:
                        kind:
                          type: string
                          enum: [immediate, delay, scroll, exit_intent, idle, manual]
                          description: >-
                            exit_intent is DESKTOP-ONLY by construction, since it is
                            detected by the cursor crossing the top viewport edge and a
                            touch device has no cursor. Use idle for the mobile
                            equivalent rather than configuring a trigger that can never
                            fire. manual shows the form only when your own page
                            JavaScript asks for it.
                        delay_seconds:
                          type: integer
                          minimum: 0
                          maximum: 3600
                          description: Applies to the delay kind. Capped at an hour, past which the shopper has left.
                        scroll_percent:
                          type: integer
                          minimum: 0
                          maximum: 100
                          description: Applies to the scroll kind.
                        idle_seconds:
                          type: integer
                          minimum: 0
                          maximum: 600
                          description: Applies to the idle kind. Capped at ten minutes.
                    frequency:
                      type: object
                      additionalProperties: false
                      description: >-
                        The capping policy the storefront enforces per browser, as two
                        independent counters: someone who closed the form and someone who
                        already joined the list are asked again on different schedules.
                      properties:
                        after_dismiss:
                          type: object
                          required: [unit]
                          additionalProperties: false
                          properties:
                            unit:
                              type: string
                              enum: [never, session, days]
                            days:
                              type: integer
                              minimum: 0
                              maximum: 730
                              description: >-
                                Applies only to the days unit, where 0 is the documented
                                escape hatch meaning "show again on the next qualifying
                                view".
                        after_submit:
                          type: object
                          required: [unit]
                          additionalProperties: false
                          description: >-
                            Defaults to never, because somebody who already joined the
                            list must not be asked again.
                          properties:
                            unit:
                              type: string
                              enum: [never, session, days]
                            days:
                              type: integer
                              minimum: 0
                              maximum: 730
                    priority:
                      type: integer
                      description: >-
                        Arbitrates which form wins when several qualify on one page view,
                        HIGHEST first. Not a position in the list: the list is paged by
                        creation time precisely so re-prioritising one form cannot shift
                        another across a page boundary.
                consent:
                  type: object
                  additionalProperties: false
                  description: >-
                    THE COMPLIANCE-CRITICAL BLOCK. Whatever text sits here is what every
                    submission taken afterwards quotes verbatim as the merchant's proof
                    that a specific person agreed to specific words.
                  properties:
                    email:
                      type: object
                      required: [mode]
                      additionalProperties: false
                      properties:
                        mode:
                          type: string
                          enum: [none, implied, checkbox]
                          description: >-
                            none does not collect the channel at all. implied makes
                            submitting the form itself the opt-in, which is lawful for
                            email under CAN-SPAM and is the single-opt-in default every
                            ESP ships. checkbox is an explicit box the shopper ticks.
                        text:
                          type: string
                          maxLength: 2000
                          description: >-
                            The exact disclosure shown beside the checkbox, REQUIRED for
                            checkbox mode and stored verbatim on every resulting
                            submission. A consent the merchant cannot quote is not a
                            consent they can defend.
                        pre_checked:
                          type: boolean
                          description: >-
                            Render the box already ticked. Lawful for email, where implied
                            consent is already allowed, so a pre-ticked box grants nothing
                            submitting the form would not have granted anyway.
                        required:
                          type: boolean
                          description: The shopper cannot submit without ticking.
                        double_opt_in:
                          type: boolean
                          description: >-
                            Route the opt-in through a confirmation email. THE ADDRESS IS
                            NOT MAILABLE until the shopper clicks through: the resulting
                            submission records consent_outcome
                            pending_double_opt_in, so a merchant counting subscribers off
                            submissions alone would be counting people they may not
                            lawfully mail. Email only.
                    sms:
                      type: object
                      required: [mode]
                      additionalProperties: false
                      properties:
                        mode:
                          type: string
                          enum: [none, checkbox]
                          description: >-
                            implied IS NOT ACCEPTED, and pre_checked and required are
                            refused with 400 sms_consent_not_explicit. TCPA express
                            written consent needs an affirmative act, and consent to
                            marketing texts may never be a condition of anything. The
                            refusal is enforced twice, in the validator and by a database
                            constraint, so a form asking for texts by implication cannot
                            be stored by any path.
                        text:
                          type: string
                          maxLength: 2000
                          description: >-
                            The exact disclosure shown beside the checkbox, REQUIRED for
                            checkbox mode and stored verbatim on every resulting
                            submission.
                        double_opt_in:
                          type: boolean
                          description: Email-only wiring; inert on this channel.
                success:
                  type: object
                  additionalProperties: false
                  description: >-
                    What the shopper sees after submitting. OPTIONAL, and omitting it on a
                    replace CLEARS it: the storefront then shows its own default
                    confirmation, which is a real configuration rather than an error.
                  properties:
                    message:
                      type: string
                      maxLength: 1000
                    redirect_url:
                      type: string
                      maxLength: 2000
                    reveal_coupon:
                      type: boolean
                      description: >-
                        Show a discount code on the success screen. With no coupon issuer
                        wired the screen simply carries no code rather than failing the
                        signup, since the shopper has already consented and joined the
                        list by then.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      consent:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                          sms:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                      created_at:
                        type: string
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            options:
                              type: array
                              items:
                                {}
                            placeholder:
                              type: string
                            required:
                              type: boolean
                            type:
                              type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      success:
                        type: object
                        properties:
                          message:
                            type: string
                          redirect_url:
                            type: string
                          reveal_coupon:
                            type: boolean
                      targeting:
                        type: object
                        properties:
                          device:
                            type: string
                          exclude_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          frequency:
                            type: object
                            properties:
                              after_dismiss:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                              after_submit:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                          include_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          priority:
                            type: integer
                          trigger:
                            type: object
                            properties:
                              delay_seconds:
                                type: integer
                              idle_seconds:
                                type: integer
                              kind:
                                type: string
                              scroll_percent:
                                type: integer
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms/{formId}:
    delete:
      summary: Remove a signup form
      operationId: delete_marketing_forms_by_formId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_forms:delete
      x-required-scopes: [write_marketing_forms, write_marketing_forms:delete]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_marketing_forms and then on write_marketing_forms:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_marketing_forms, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a marketing form
      description: >-
        One signup form, in the same shape the list and the writes serve.
      operationId: get_marketing_forms_by_formId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_forms
      x-required-scopes: [read_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      consent:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                          sms:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                      created_at:
                        type: string
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            options:
                              type: array
                              items:
                                {}
                            placeholder:
                              type: string
                            required:
                              type: boolean
                            type:
                              type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      success:
                        type: object
                        properties:
                          message:
                            type: string
                          redirect_url:
                            type: string
                          reveal_coupon:
                            type: boolean
                      targeting:
                        type: object
                        properties:
                          device:
                            type: string
                          exclude_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          frequency:
                            type: object
                            properties:
                              after_dismiss:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                              after_submit:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                          include_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          priority:
                            type: integer
                          trigger:
                            type: object
                            properties:
                              delay_seconds:
                                type: integer
                              idle_seconds:
                                type: integer
                              kind:
                                type: string
                              scroll_percent:
                                type: integer
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a signup form
      operationId: put_marketing_forms_by_formId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_forms
      x-required-scopes: [write_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, status, fields, targeting, consent]
              additionalProperties: false
              description: >-
                A FULL REPLACE. Every definition block is rewritten, so an omitted success
                is CLEARED and the five required keys are refused when absent rather than
                defaulted. See the create for why consent and status in particular cannot
                be defaulted.
                type IS REFUSED BY NAME here, with a 400. A form's type is immutable, and
                accepting the key would answer 200 to a client that believed it had
                converted a pop-up into an embed.
                THIS CHANGES WHAT A SHOPPER IS SHOWN AND ASKED TO AGREE TO, on the live
                storefront, from the next request. Every submission taken afterwards
                snapshots the new consent wording as evidence.
              properties:
                name:
                  type: string
                  maxLength: 120
                  description: >-
                    Unique per store and compared case-insensitively, which makes it a
                    stable handle a migration tool can re-run against. A duplicate is 409
                    name_taken. Merchant-facing only; a shopper never sees it.
                status:
                  type: string
                  enum: [draft, active, paused]
                  description: >-
                    ONLY active is served to shoppers. Setting it here activates or pauses
                    the form as part of the same write; the dedicated activate and pause
                    routes are the way to do it without touching the definition.
                fields:
                  type: array
                  minItems: 1
                  maxItems: 12
                  description: >-
                    The ordered input list. EXACTLY ONE field must have type email: it is
                    the identity a submission keys a contact on, and a form with none has
                    nothing to key while a form with two is an ambiguity on the identity
                    path. An email field is always stored as required whatever you send,
                    because an optional identity is not an identity.
                  items:
                    type: object
                    required: [key, type]
                    additionalProperties: false
                    properties:
                      key:
                        type: string
                        maxLength: 40
                        description: >-
                          Lower-case ascii, digits and underscores, starting with a
                          letter, and unique within the form. It is the key a submission's
                          values are recorded under, so changing it orphans the answers
                          already collected under the old one.
                      type:
                        type: string
                        enum: [email, phone, first_name, last_name, text, number, date, select, checkbox, hidden]
                        description: >-
                          A CLOSED vocabulary with no escape hatch. phone values must be
                          E.164 when submitted, since sms consent is never recorded
                          against a string that is not a dialable number.
                      label:
                        type: string
                        maxLength: 120
                      placeholder:
                        type: string
                        maxLength: 120
                      required:
                        type: boolean
                      options:
                        type: array
                        items: { type: string }
                        maxItems: 20
                        description: >-
                          The choice list for a select field, between 1 and 20 entries.
                          Dropped rather than refused on every other type, so a dashboard
                          leaving a stale list on a field whose type changed does not fail
                          the save.
                targeting:
                  type: object
                  required: [device, trigger]
                  additionalProperties: false
                  description: Where, when and to whom the form shows.
                  properties:
                    include_urls:
                      type: array
                      maxItems: 20
                      items:
                        type: object
                        required: [op, value]
                        additionalProperties: false
                        properties:
                          op:
                            type: string
                            enum: [contains, exact, prefix]
                            description: >-
                              Three operators and deliberately NO regex: merchant-authored
                              regex on a public storefront hot path is a denial-of-service
                              waiting to happen.
                          value:
                            type: string
                            maxLength: 500
                      description: Empty or omitted means every page.
                    exclude_urls:
                      type: array
                      maxItems: 20
                      items:
                        type: object
                        required: [op, value]
                        additionalProperties: false
                        properties:
                          op:
                            type: string
                            enum: [contains, exact, prefix]
                            description: >-
                              Three operators and deliberately NO regex: merchant-authored
                              regex on a public storefront hot path is a denial-of-service
                              waiting to happen.
                          value:
                            type: string
                            maxLength: 500
                      description: Always wins over include_urls.
                    device:
                      type: string
                      enum: [all, desktop, mobile]
                      description: >-
                        Tablets fold into desktop: a tablet has a cursor-like pointer and
                        a desktop-width viewport, so a three-way split would be a
                        configuration decision that changes nothing a shopper sees.
                    trigger:
                      type: object
                      required: [kind]
                      additionalProperties: false
                      properties:
                        kind:
                          type: string
                          enum: [immediate, delay, scroll, exit_intent, idle, manual]
                          description: >-
                            exit_intent is DESKTOP-ONLY by construction, since it is
                            detected by the cursor crossing the top viewport edge and a
                            touch device has no cursor. Use idle for the mobile
                            equivalent rather than configuring a trigger that can never
                            fire. manual shows the form only when your own page
                            JavaScript asks for it.
                        delay_seconds:
                          type: integer
                          minimum: 0
                          maximum: 3600
                          description: Applies to the delay kind. Capped at an hour, past which the shopper has left.
                        scroll_percent:
                          type: integer
                          minimum: 0
                          maximum: 100
                          description: Applies to the scroll kind.
                        idle_seconds:
                          type: integer
                          minimum: 0
                          maximum: 600
                          description: Applies to the idle kind. Capped at ten minutes.
                    frequency:
                      type: object
                      additionalProperties: false
                      description: >-
                        The capping policy the storefront enforces per browser, as two
                        independent counters: someone who closed the form and someone who
                        already joined the list are asked again on different schedules.
                      properties:
                        after_dismiss:
                          type: object
                          required: [unit]
                          additionalProperties: false
                          properties:
                            unit:
                              type: string
                              enum: [never, session, days]
                            days:
                              type: integer
                              minimum: 0
                              maximum: 730
                              description: >-
                                Applies only to the days unit, where 0 is the documented
                                escape hatch meaning "show again on the next qualifying
                                view".
                        after_submit:
                          type: object
                          required: [unit]
                          additionalProperties: false
                          description: >-
                            Defaults to never, because somebody who already joined the
                            list must not be asked again.
                          properties:
                            unit:
                              type: string
                              enum: [never, session, days]
                            days:
                              type: integer
                              minimum: 0
                              maximum: 730
                    priority:
                      type: integer
                      description: >-
                        Arbitrates which form wins when several qualify on one page view,
                        HIGHEST first. Not a position in the list: the list is paged by
                        creation time precisely so re-prioritising one form cannot shift
                        another across a page boundary.
                consent:
                  type: object
                  additionalProperties: false
                  description: >-
                    THE COMPLIANCE-CRITICAL BLOCK. Whatever text sits here is what every
                    submission taken afterwards quotes verbatim as the merchant's proof
                    that a specific person agreed to specific words.
                  properties:
                    email:
                      type: object
                      required: [mode]
                      additionalProperties: false
                      properties:
                        mode:
                          type: string
                          enum: [none, implied, checkbox]
                          description: >-
                            none does not collect the channel at all. implied makes
                            submitting the form itself the opt-in, which is lawful for
                            email under CAN-SPAM and is the single-opt-in default every
                            ESP ships. checkbox is an explicit box the shopper ticks.
                        text:
                          type: string
                          maxLength: 2000
                          description: >-
                            The exact disclosure shown beside the checkbox, REQUIRED for
                            checkbox mode and stored verbatim on every resulting
                            submission. A consent the merchant cannot quote is not a
                            consent they can defend.
                        pre_checked:
                          type: boolean
                          description: >-
                            Render the box already ticked. Lawful for email, where implied
                            consent is already allowed, so a pre-ticked box grants nothing
                            submitting the form would not have granted anyway.
                        required:
                          type: boolean
                          description: The shopper cannot submit without ticking.
                        double_opt_in:
                          type: boolean
                          description: >-
                            Route the opt-in through a confirmation email. THE ADDRESS IS
                            NOT MAILABLE until the shopper clicks through: the resulting
                            submission records consent_outcome
                            pending_double_opt_in, so a merchant counting subscribers off
                            submissions alone would be counting people they may not
                            lawfully mail. Email only.
                    sms:
                      type: object
                      required: [mode]
                      additionalProperties: false
                      properties:
                        mode:
                          type: string
                          enum: [none, checkbox]
                          description: >-
                            implied IS NOT ACCEPTED, and pre_checked and required are
                            refused with 400 sms_consent_not_explicit. TCPA express
                            written consent needs an affirmative act, and consent to
                            marketing texts may never be a condition of anything. The
                            refusal is enforced twice, in the validator and by a database
                            constraint, so a form asking for texts by implication cannot
                            be stored by any path.
                        text:
                          type: string
                          maxLength: 2000
                          description: >-
                            The exact disclosure shown beside the checkbox, REQUIRED for
                            checkbox mode and stored verbatim on every resulting
                            submission.
                        double_opt_in:
                          type: boolean
                          description: Email-only wiring; inert on this channel.
                success:
                  type: object
                  additionalProperties: false
                  description: >-
                    What the shopper sees after submitting. OPTIONAL, and omitting it on a
                    replace CLEARS it: the storefront then shows its own default
                    confirmation, which is a real configuration rather than an error.
                  properties:
                    message:
                      type: string
                      maxLength: 1000
                    redirect_url:
                      type: string
                      maxLength: 2000
                    reveal_coupon:
                      type: boolean
                      description: >-
                        Show a discount code on the success screen. With no coupon issuer
                        wired the screen simply carries no code rather than failing the
                        signup, since the shopper has already consented and joined the
                        list by then.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      consent:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                          sms:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                      created_at:
                        type: string
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            options:
                              type: array
                              items:
                                {}
                            placeholder:
                              type: string
                            required:
                              type: boolean
                            type:
                              type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      success:
                        type: object
                        properties:
                          message:
                            type: string
                          redirect_url:
                            type: string
                          reveal_coupon:
                            type: boolean
                      targeting:
                        type: object
                        properties:
                          device:
                            type: string
                          exclude_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          frequency:
                            type: object
                            properties:
                              after_dismiss:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                              after_submit:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                          include_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          priority:
                            type: integer
                          trigger:
                            type: object
                            properties:
                              delay_seconds:
                                type: integer
                              idle_seconds:
                                type: integer
                              kind:
                                type: string
                              scroll_percent:
                                type: integer
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms/{formId}/activate:
    post:
      summary: Activate a signup form
      operationId: post_marketing_forms_by_formId_activate
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_forms
      x-required-scopes: [write_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                THE FORM IS IN FRONT OF SHOPPERS ON THE NEXT STOREFRONT REQUEST, with
                whatever consent wording it currently carries, and every submission taken
                from that moment quotes that wording as evidence. Read the form first if
                you did not write it.
                A ROUTE rather than a status field on the replace, so that a bug in a
                replace body cannot ALSO rewrite the consent block in the same call: this
                touches status and nothing else.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      consent:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                          sms:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                      created_at:
                        type: string
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            options:
                              type: array
                              items:
                                {}
                            placeholder:
                              type: string
                            required:
                              type: boolean
                            type:
                              type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      success:
                        type: object
                        properties:
                          message:
                            type: string
                          redirect_url:
                            type: string
                          reveal_coupon:
                            type: boolean
                      targeting:
                        type: object
                        properties:
                          device:
                            type: string
                          exclude_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          frequency:
                            type: object
                            properties:
                              after_dismiss:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                              after_submit:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                          include_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          priority:
                            type: integer
                          trigger:
                            type: object
                            properties:
                              delay_seconds:
                                type: integer
                              idle_seconds:
                                type: integer
                              kind:
                                type: string
                              scroll_percent:
                                type: integer
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms/{formId}/pause:
    post:
      summary: Pause a signup form
      operationId: post_marketing_forms_by_formId_pause
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_forms
      x-required-scopes: [write_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                THE REVERSIBLE ALTERNATIVE TO DELETE, and it is reachable on
                write_marketing_forms alone. The storefront stops serving the form on the
                next request and every field, counter and submission is kept, so an
                integration that only needs a form to stop never has to ask the merchant
                for write_marketing_forms:delete.
                Activate puts it back exactly as it was.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      consent:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                          sms:
                            type: object
                            properties:
                              double_opt_in:
                                type: boolean
                              mode:
                                type: string
                              pre_checked:
                                type: boolean
                              required:
                                type: boolean
                              text:
                                type: string
                      created_at:
                        type: string
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            options:
                              type: array
                              items:
                                {}
                            placeholder:
                              type: string
                            required:
                              type: boolean
                            type:
                              type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      success:
                        type: object
                        properties:
                          message:
                            type: string
                          redirect_url:
                            type: string
                          reveal_coupon:
                            type: boolean
                      targeting:
                        type: object
                        properties:
                          device:
                            type: string
                          exclude_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          frequency:
                            type: object
                            properties:
                              after_dismiss:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                              after_submit:
                                type: object
                                properties:
                                  days:
                                    type: integer
                                  unit:
                                    type: string
                          include_urls:
                            type: array
                            items:
                              type: object
                              properties:
                                op:
                                  type: string
                                value:
                                  type: string
                          priority:
                            type: integer
                          trigger:
                            type: object
                            properties:
                              delay_seconds:
                                type: integer
                              idle_seconds:
                                type: integer
                              kind:
                                type: string
                              scroll_percent:
                                type: integer
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms/{formId}/stats:
    get:
      summary: Retrieve marketing form stats
      description: >-
        One form's lifetime counters and its conversion rate. EVENT COUNTS, not unique
        people: one shopper who saw a pop-up on four pages contributes four
        impressions. submit_rate is submissions divided by impressions as a ratio
        between 0 and 1, and exactly 0 when nothing has been shown yet.
        A form with no traffic answers zeros rather than 404, because "nobody has seen
        this form" is the true answer. A form id that is not this store's is still 404.
      operationId: get_marketing_forms_by_formId_stats
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_forms
      x-required-scopes: [read_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      dismissals:
                        type: integer
                      form_id:
                        type: string
                      impressions:
                        type: integer
                      submissions:
                        type: integer
                      submit_rate:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-forms/{formId}/submissions:
    get:
      summary: List a form's submissions
      description: >-
        The signups this form collected, newest first. APPEND-ONLY EVIDENCE rather than
        a lead list: each row records what a person typed AND the exact consent wording
        they were shown while agreeing, which is what makes it the merchant's proof in a
        dispute. Nothing on this platform can edit or remove one.
        READ consent_outcome, NOT email_consent_granted, to decide whether an address
        is mailable. A form running double opt-in records granted true and outcome
        pending_double_opt_in, and that address must not be mailed until the shopper
        confirms; suppressed_unsubscribed means the person had already opted out and a
        public form was not allowed to re-subscribe them.
        The shopper's ip and user agent are stored for a dispute and are deliberately
        NOT published here.
        form_id is a loose reference: evidence outlives the form that produced it, so a
        row can name a form that has since been deleted.
      operationId: get_marketing_forms_by_formId_submissions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_forms
      x-required-scopes: [read_marketing_forms]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        consent_outcome:
                          type: string
                        contact_id:
                          type: string
                        email:
                          type: string
                        email_consent_granted:
                          type: boolean
                        email_consent_text:
                          type: string
                        form_id:
                          type: string
                        id:
                          type: string
                        occurred_at:
                          type: string
                        page_url:
                          type: string
                        phone:
                          nullable: true
                        sms_consent_granted:
                          type: boolean
                        sms_consent_text:
                          nullable: true
                        values:
                          type: object
                          properties:
                            email:
                              type: string
                            first_name:
                              type: string
                            how_heard:
                              type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_forms scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-preferences/settings:
    get:
      summary: Retrieve marketing preference settings
      description: >-
        Whether this store's preference centre is live. A SINGLETON: one answer per
        tenant, so there is no page block and no id in the path.
        THE PRE-FLIGHT CHECK FOR A SEND. links_available false means no working
        unsubscribe link can be produced for this store, so mail composed against it
        would go out without one. HOLD the send, which is what this platform's own email
        path does when the same preconditions fail.
        It is read-only. The switch is flipped by the merchant in their dashboard,
        because turning it off strands every preference link already sitting in a
        recipient's inbox.
      operationId: get_marketing_preferences_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_contacts
      x-required-scopes: [read_marketing_contacts]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      links_available:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-preferences/{contactId}:
    get:
      summary: Retrieve a marketing preference
      description: >-
        Whether this person may be mailed, per channel, right now. Read it immediately
        before a send.
        BRANCH ON subscribed, NOT ON state. state carries the full vocabulary
        (subscribed, pending, unsubscribed, never) and only the first is mailable:
        pending is an opt-in that was never confirmed and never is silence rather than a
        recorded refusal. subscribed collapses the three non-mailable states into false,
        so the safe read is also the short one.
        erased OVERRIDES EVERY CHANNEL. It means this person's data was wiped under an
        erasure request, so the address is gone and nothing may be sent whatever the last
        consent state said.
        email is null for a contact resolved from an anonymous id or a phone number, and
        for one that has been erased. Key your suppression records on contact_id and
        treat the address as a label.
        THERE IS NO COLLECTION HERE. A list would be the marketing database re-served
        through a second path; read /api/v1/marketing-contacts and filter on the consent
        fields it carries.
      operationId: get_marketing_preferences_by_contactId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_contacts
      x-required-scopes: [read_marketing_contacts]
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channels:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                          push:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                          sms:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                      contact_id:
                        type: string
                      email:
                        nullable: true
                      erased:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-preferences/{contactId}/links:
    post:
      summary: Mint this contact's preference and unsubscribe links
      operationId: post_marketing_preferences_by_contactId_links
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_contacts
      x-required-scopes: [write_marketing_contacts]
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Use this when you
                send marketing mail through your own provider: the unsubscribe_url is the
                functional CAN-SPAM link and belongs both in the mail body and in the
                List-Unsubscribe header.
                A WRITE DESPITE STORING NOTHING. The response is a CAPABILITY: the token
                in these URLs is what authorises a consent change from an inbox, so
                anyone who holds one can unsubscribe this contact. Treat a minted link as
                a secret belonging to that one recipient, and mint per recipient rather
                than reusing one.
                THE BODY IS NOT REPLAYABLE. This route is exempt from the idempotency
                ledger's response storage, because storing a capability there would keep
                it readable long after the send; a replayed Idempotency-Key answers 201
                with an empty body. Use a fresh key, or mint again, rather than replaying.
                THE LINKS DO NOT EXPIRE by default, which is deliberate: a recipient may
                act on a months-old email, and a link that quietly expired into a failure
                is itself a compliance breach.
                409 preference_center_disabled when the merchant has the surface switched
                off, and 409 preference_center_not_configured when the platform cannot
                sign a link at all. Both mean the same thing to a sender: there is no
                working unsubscribe link, so HOLD the send rather than mailing without
                one. GET /api/v1/marketing-preferences/settings answers that ahead of
                time without spending a write.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contact_id:
                        type: string
                      preference_center_url:
                        type: string
                      unsubscribe_url:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-preferences/{contactId}/unsubscribe:
    post:
      summary: Suppress a contact on the merchant's authority
      operationId: post_marketing_preferences_by_contactId_unsubscribe
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_contacts
      x-required-scopes: [write_marketing_contacts]
      parameters:
        - name: contactId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel]
              additionalProperties: false
              description: >-
                THE ROUTE TO USE FOR A SUPPRESSION SYNC. It takes no unsubscribe token,
                unlike the preference centre a recipient reaches from their inbox,
                because a token proves the recipient clicked a link and an integration
                importing complaints from an ESP has no such proof. Your api key is the
                merchant's authority, and the consent ledger records that: the row is
                written with source public_api and names the key that asked, so an audit
                can tell it from a recipient's own click.
                IT ONLY EVER SUPPRESSES. There is no re-subscribe on this API and no
                full replace of a contact's preferences. An api key is not evidence that
                a person opted in, and a replace echoing back a document read an hour ago
                would silently resurrect consent for someone who unsubscribed in between.
                Record a genuine opt-in at POST
                /api/v1/marketing-contacts/{marketingContactId}/consent, where the source
                and the disclosure text actually shown are yours to state.
                IDEMPOTENT WITHOUT A REPLAY. A channel already unsubscribed is a no-op
                that answers 200 and writes no second ledger row, so a nightly reconciler
                can run against the whole list without inflating the audit trail.
                THE RESPONSE IS THE RESULTING STATE, which is what makes channel all
                worth a single call: three channels move and you see all three.
                404 contact_not_found when the id is not this store's.
              properties:
                channel:
                  type: string
                  enum: [email, sms, push, all]
                  description: >-
                    The channel to suppress. REQUIRED and never defaulted: a client that
                    meant email and dropped the key would otherwise suppress sms and push
                    too, and this API publishes no way to undo that. all fans out to
                    every channel, including push, which the preference page does not
                    render.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channels:
                        type: object
                        properties:
                          email:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                          push:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                          sms:
                            type: object
                            properties:
                              state:
                                type: string
                              subscribed:
                                type: boolean
                      contact_id:
                        type: string
                      email:
                        nullable: true
                      erased:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_contacts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates:
    get:
      summary: List marketing templates
      description: >-
        The store's marketing message library, newest first. A template holds the
        merchant's WORKING COPY, which is what the next publish will snapshot and is not
        necessarily what is live: current_version_id names the version the send path
        renders.
        TWO FILTERS, NOT TWO ROUTES. ?channel= narrows to email, sms or push and
        ?status= to draft, active or archived. A value outside either enum is a 400
        rather than an unfiltered page, because a page that quietly ignored your filter
        reads as "the store has no sms templates" when it has ten.
        PAGED BY CREATION TIME, not by updated_at. Ordering on updated_at would move a
        template to the front of the list every time the merchant saved it, and a client
        walking the library would meet the same row twice.
      operationId: get_marketing_templates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: channel
          in: query
          required: false
          schema: { type: string, enum: [email, sms, push] }
          description: >-
            Narrow to one surface. Omit it for all three. An unrecognised value is a 400
            rather than an unfiltered page: a client filtering on "e-mail" and receiving
            every template in the store would read an sms body as email copy.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, active, archived] }
          description: >-
            Filter by lifecycle state. Omit it to receive all three. ONLY an active
            template has a published version behind it, so ?status=active is how you ask
            what a campaign can send right now; draft has never been published and
            archived has been retired, and neither is sendable.
            An unrecognised value is a 400 for the same reason as channel.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Templates touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the library on every run. It composes with channel and status.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends created_at,
            and this one climbs updated_at. The default walk avoids updated_at on purpose, because
            descending it every save would move a template to the front and you would see the same
            row twice; ascending, a row can only move FORWARD past your position, so seeing it
            again is the guarantee rather than the bug and the walk is AT LEAST ONCE. WHAT A MISS
            COSTS HERE IS THE WRONG BODY ON A SEND: the body columns are what a campaign renders,
            so a client that missed an edit sends copy the merchant replaced. A cursor from one
            ordering is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        channel:
                          type: string
                        created_at:
                          type: string
                        current_version:
                          type: number
                        current_version_id:
                          type: [string, "null"]
                        html_body:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        status:
                          type: string
                        subject:
                          type: string
                        text_body:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a marketing template
      description: >-
        Creates a template. It ALWAYS LANDS AS A DRAFT and this route can never put copy
        in front of a recipient: a draft is not sendable and publishing is a second,
        explicitly named call. That is what makes a bulk library import safe to run.
        channel is settable exactly once, here, because every body on the row is
        validated against it. status, current_version_id and current_version are refused
        by name.
        Names are unique per store, so a repeat is 409 name_taken rather than a second
        template nobody can tell apart. That is also what makes a migration safe to
        re-run with fresh idempotency keys.
      operationId: post_marketing_templates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, channel]
              additionalProperties: false
              description: >-
                A template ALWAYS lands as a draft and this route can never make copy
                sendable: publishing is a second, explicitly named call. status,
                current_version_id and current_version are refused by name for that
                reason.
                The three content fields are optional here and REQUIRED on the replace.
                Nothing is being overwritten by a create, so an omitted body destroys
                nothing; on a replace it would blank the merchant's stored copy.
              properties:
                name:
                  type: string
                  description: >-
                    What the merchant sees in their own dashboard, at most 200 bytes.
                    Unique per store, so a repeat is 409 name_taken rather than a second
                    template nobody can tell apart.
                channel:
                  type: string
                  enum: [email, sms, push]
                  description: >-
                    Which surface this copy is written for, and IMMUTABLE afterwards:
                    every body on the row is validated against it, so the replace refuses
                    the key by name. Only an email template may carry a subject or an
                    html body.
                subject:
                  type: string
                  description: >-
                    The subject line, at most 500 bytes, and EMAIL ONLY. Sending one on
                    an sms or push template is 400 subject_not_allowed rather than a
                    silent drop, because a merchant who wrote a subject meant it to be
                    delivered and a drop is discovered in the inbox.
                    An email template needs one before it can be published.
                html_body:
                  type: string
                  description: >-
                    The HTML part, at most 256 KB, and EMAIL ONLY: sms and push render
                    plain text, so an html body there could never be delivered as
                    written and is 400 html_not_allowed.
                    Stored VERBATIM, with no trimming, so a read-modify-write round trip
                    converges.
                text_body:
                  type: string
                  description: >-
                    The plain-text part, at most 256 KB. It is the whole message on sms
                    and push, and the text alternative on email.
                    MERGE TAGS ARE A CLOSED VOCABULARY, checked in every field at save
                    time. The legal names are first_name, last_name, email, phone,
                    orders_count, total_spent, store_name, order_total, unsubscribe_url
                    and preferences_url, each written as {{name}}. Anything else is 400
                    unknown_merge_tag naming the tag, and an unclosed brace is 400
                    malformed_merge_tag. Catching it here is what stops a foreign
                    platform's tag shipping as a hole in a delivered message.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel:
                        type: string
                      created_at:
                        type: string
                      current_version:
                        type: number
                      current_version_id:
                        nullable: true
                      html_body:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      text_body:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}:
    get:
      summary: Retrieve a marketing template
      description: >-
        One template, working copy included. html_body and text_body come back verbatim,
        byte for byte as stored, which is what makes the replace safe to build on: read
        this, change one field, send the whole thing back.
        WHAT YOU READ HERE IS NOT NECESSARILY WHAT WENT OUT. For the copy a campaign
        actually sent, follow current_version_id to the version read below, or use the
        version id the campaign itself recorded.
      operationId: get_marketing_templates_by_templateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel:
                        type: string
                      created_at:
                        type: string
                      current_version:
                        type: number
                      current_version_id:
                        type: [string, "null"]
                      html_body:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      text_body:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a template's working copy
      description: >-
        Replaces the template's WORKING COPY. It deliberately does not touch the
        published version, so a campaign that already sent keeps resolving to the copy it
        sent; what you change here is what the NEXT publish will contain.
        A FULL REPLACE, WHICH IS WHY IT IS PUT. An omitted key is cleared, not kept, so
        all four fields are required and a missing one is a 400 naming it. On an sms or
        push template send "" for subject and html_body.
        channel is refused by name: a template's channel is fixed at creation, and
        changing it would leave an HTML body on a surface that renders plain text.
        THERE IS NO PRECONDITION ON THIS WRITE. updated_at is published so you can detect
        that the merchant's dashboard changed the template under you, but the last writer
        wins. If two systems author the same template, coordinate on your side.
      operationId: put_marketing_templates_by_templateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, subject, html_body, text_body]
              additionalProperties: false
              description: >-
                EVERY FIELD IS REQUIRED because this is a full replace: an omitted key is
                cleared, not kept. A client that dropped text_body would blank the
                plain-text alternative of the merchant's email, the next publish would
                snapshot the half-template, and the send would still go out with every
                call answering 200.
                Empty strings are legal and are how a non-email template says "no
                subject, no html". null is not: send "" to clear a field.
                channel is refused by name. A template's channel is fixed at creation,
                and changing it would leave an html body on a surface that renders plain
                text.
              properties:
                name:
                  type: string
                  description: Still unique per store; a clash is 409 name_taken.
                subject:
                  type: string
                  description: >-
                    Email only. Send "" on an sms or push template; a non-empty value
                    there is 400 subject_not_allowed.
                html_body:
                  type: string
                  description: >-
                    Email only, stored verbatim. Send "" on an sms or push template; a
                    non-empty value there is 400 html_not_allowed.
                text_body:
                  type: string
                  description: >-
                    Stored verbatim. Same closed merge-tag vocabulary as the create, so
                    a body carrying an unknown tag is refused rather than delivered with
                    a hole in it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel:
                        type: string
                      created_at:
                        type: string
                      current_version:
                        type: number
                      current_version_id:
                        type: [string, "null"]
                      html_body:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      text_body:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}/archive:
    post:
      summary: Retire a template, keeping its published versions
      description: >-
        Retires a template. THIS IS WHAT THIS FAMILY PUBLISHES INSTEAD OF A DELETE, and
        there is no delete anywhere on it: a delivered message resolves its copy through
        a published version, so a deletable version would strand one.
        An archived template stops being sendable while every published version stays
        readable and renderable, so campaign history keeps resolving. restore is the
        exact inverse, which is what makes this safe to hand an integration.
      operationId: post_marketing_templates_by_templateId_archive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The response is the
                template with status archived, which is the proof it is no longer
                sendable. restore is the exact inverse, which is why this is a POST and
                not a DELETE.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel:
                        type: string
                      created_at:
                        type: string
                      current_version:
                        type: number
                      current_version_id:
                        type: [string, "null"]
                      html_body:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      text_body:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}/publish:
    post:
      summary: Publish a template as a new immutable version
      description: >-
        Snapshots the working copy into a new immutable version and points the template
        at it. This is what makes a template sendable, and it is the ONLY route that
        does.
        IT ANSWERS THE VERSION, NOT THE TEMPLATE, because a version is what it creates.
        Pin the id in the response if you are recording what a send used.
        APPEND ONLY. The new snapshot never replaces an old one, so every campaign that
        pinned an earlier version keeps resolving to exactly the copy it sent. Nothing
        the merchant had before this call is different afterwards except which version is
        current, and the next publish moves that again.
        An email template with no subject is 400 subject_required and a template with no
        body at all is 400 empty_body, because a snapshot no campaign could send is worse
        than a refusal.
      operationId: post_marketing_templates_by_templateId_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. What is snapshotted
                is whatever the working copy holds right now, so write the copy with the
                replace first and publish it second; there is no way to hand content to
                this route.
              properties: {}
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      html_body:
                        type: string
                      id:
                        type: string
                      subject:
                        type: string
                      template_id:
                        type: string
                      text_body:
                        type: string
                      version:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}/restore:
    post:
      summary: Bring an archived template back
      description: >-
        Brings an archived template back. It returns to active when it has a published
        version and to draft when it never had one, so status never claims more than the
        version pointer backs: a draft that called itself active would be a template a
        campaign could target with nothing to render.
      operationId: post_marketing_templates_by_templateId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_marketing_campaigns
      x-required-scopes: [write_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Read status off the
                response rather than assuming active: a template that never had a
                published version comes back as a draft, because status must never claim
                more than the version pointer backs.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel:
                        type: string
                      created_at:
                        type: string
                      current_version:
                        type: number
                      current_version_id:
                        type: [string, "null"]
                      html_body:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      status:
                        type: string
                      subject:
                        type: string
                      text_body:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}/versions:
    get:
      summary: List a template's versions
      description: >-
        Every published snapshot of one template, newest first. This is the changelog: a
        row is created by each publish and nothing ever updates one.
        A TEMPLATE THIS STORE DOES NOT HAVE IS A 404, not an empty page. An empty page
        would be indistinguishable from a template nobody has published yet, so a client
        reconciling history against a mistyped id would record "no versions" and never
        learn otherwise.
      operationId: get_marketing_templates_by_templateId_versions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        html_body:
                          type: string
                        id:
                          type: string
                        subject:
                          type: string
                        template_id:
                          type: string
                        text_body:
                          type: string
                        version:
                          type: number
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/marketing-templates/{templateId}/versions/{versionId}:
    get:
      summary: Retrieve a marketing template version
      description: >-
        One immutable snapshot, byte for byte as it was published. THIS IS THE ROUTE THAT
        ANSWERS "WHAT DID THAT CAMPAIGN ACTUALLY SEND": a campaign records the version id
        it rendered, and this resolves it however many times the merchant has rewritten
        the template since.
        Nothing can change this row. The version table is append-only, there is no write
        that touches it, and the database role serving this API holds no UPDATE or DELETE
        grant on it.
        THE VERSION IS ADDRESSED UNDER ITS PARENT and the parent is part of the lookup,
        not decoration: a version id belonging to a different template of the same store
        is a 404 here rather than a body from the wrong message.
      operationId: get_marketing_templates_by_templateId_versions_by_versionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_marketing_campaigns
      x-required-scopes: [read_marketing_campaigns]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: versionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      html_body:
                        type: string
                      id:
                        type: string
                      subject:
                        type: string
                      template_id:
                        type: string
                      text_body:
                        type: string
                      version:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_marketing_campaigns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/media-files:
    get:
      summary: List media files
      operationId: get_media_files
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_files
      x-required-scopes: [read_files]
      parameters:
        - name: kind
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this kind. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        content_type:
                          type: string
                        created_at:
                          type: string
                        filename:
                          type: string
                        height:
                          type: integer
                        id:
                          type: string
                        kind:
                          type: string
                        size_bytes:
                          type: integer
                        url:
                          type: string
                        width:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_files scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Upload a file to the store's media library
      operationId: post_media_files
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_files
      x-required-scopes: [write_files]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              additionalProperties: false
              description: >-
                MULTIPART/FORM-DATA, NOT JSON, and the only route on this API that is. Post the
                raw file in a part named "file"; the backend streams it into the store's object
                storage, records the row, and answers 201 with that row in the shape
                GET /api/v1/media-files/{mediaFileId} serves.
                THE 1 MiB ENVELOPE IS A HARD CEILING ON THIS ROUTE. Every write on this API
                passes through the idempotency ledger, which buffers the whole request body to
                hash it under a 1 MiB bound, so a larger upload is refused with 413
                payload_too_large BEFORE this route runs. The storage layer's own per-object
                ceiling is far larger and is also enforced, so the binding limit is whichever
                is smaller, which today is always the envelope. This is a property of the
                mechanism rather than a tuning value, so size your assets for it: a
                photograph straight off a phone or a camera will not fit.
                IT SPENDS THE MERCHANT'S STORAGE ALLOWANCE, checked against the received byte
                count BEFORE the transfer. An upload that would take the store past its plan is
                402 storage_quota_exceeded, which is not a permissions failure and never
                succeeds on retry: the merchant frees space or upgrades. No money moves on this
                call, but the metered resource it consumes is the one their plan is priced on.
                WHAT A RETRY DOES, and this is the route where it matters most, because nothing
                downstream deduplicates. Retry with the SAME Idempotency-Key and BYTE-IDENTICAL
                bytes: the ledger replays the stored 201 and no second object is written, and
                this is the retry to make. Retry with the same key after RE-ENCODING the form:
                most clients generate a fresh multipart boundary per request, so the body hash
                differs and the answer is 409 idempotency_key_reused with nothing uploaded,
                which means a client that wants replay must buffer the encoded form rather than
                rebuild it. Retry with a NEW key: a SECOND object and a SECOND library row,
                both counted against the plan, because every call mints a fresh object key and
                there is nothing for it to collide with. Retry after a 5xx: the ledger releases
                the key and whether the first attempt left an orphan object depends on where it
                failed; an object with no row is invisible to every route and is swept
                automatically.
                UPLOADING IS NOT ATTACHING. Bytes landed here are inert until something points
                at them, so product_id and variant_id are refused BY NAME: a product image is
                write_products' resource and is attached through the product routes.
                THE FILE IS PUBLIC, and that is not a choice this route offers. Store media is
                served from the cdn so galleries and the storefront can render it, and the
                response carries a plain non-expiring URL, so visibility is refused by name
                rather than accepted and ignored.
                REFUSED BY NAME beside those: kind, because it is derived from the content type
                rather than declared; content_type and filename, because both come from the
                file part's own headers and describe the bytes being sent instead of sitting
                beside them as a claim; size_bytes, because the server counts what it received
                and a declared figure that disagreed would be a storage number the merchant is
                billed on that nobody measured; object_key, because the server mints it under
                this store's namespace and a caller-supplied key is how one tenant would write
                into another's prefix; url, because it is derived after the bytes land; alt,
                because there is no alt-text column and text sent here would be discarded; and
                the provenance keys (id, store_id, created_at). A SECOND FILE PART is refused
                too: a form carrying "file" and "thumbnail" would otherwise upload one and drop
                the other under a 201 that read as though both had landed.
              properties:
                file:
                  type: string
                  format: binary
                  description: >-
                    Required, and the part must be named exactly "file". Its own Content-Type
                    header decides the content type, falling back to the filename extension
                    when the client sends none, and that type is checked against a per-kind
                    allowlist: images may be png, jpeg, webp, avif or gif, and videos mp4, webm
                    or quicktime. Anything else is 400 unsupported_file_type, which is where an
                    svg lands: it is absent from the allowlist deliberately, because an svg can
                    carry script and this file would be served from the merchant's own public
                    origin. The kind recorded on the row is derived from that type, video for
                    video/* and image for everything else. The part must carry a filename,
                    under 512 bytes, kept as the display name while the storage key gets a
                    sanitised form of it; an empty part is 400 invalid_size.
                width:
                  type: integer
                  minimum: 0
                  description: >-
                    Optional pixel width, and it is THE CALLER'S WORD: nothing decodes the
                    image to check it, exactly as on the dashboard's own upload. It is accepted
                    anyway because the read publishes width and height, so a file uploaded here
                    that could not declare them would be permanently second-class in the
                    library this same API lists. Omit it and the field reads null. A
                    non-numeric value is a 400 rather than being silently dropped, and a
                    negative one is 400 invalid_dimensions.
                height:
                  type: integer
                  minimum: 0
                  description: >-
                    Optional pixel height, on exactly the same terms as width.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      content_type:
                        type: string
                      created_at:
                        type: string
                      filename:
                        type: string
                      height:
                        type: integer
                      id:
                        type: string
                      kind:
                        type: string
                      size_bytes:
                        type: integer
                      url:
                        type: string
                      width:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_files scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/media-files/{mediaFileId}:
    get:
      summary: Retrieve a media file
      operationId: get_media_files_by_mediaFileId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_files
      x-required-scopes: [read_files]
      parameters:
        - name: mediaFileId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      content_type:
                        type: string
                      created_at:
                        type: string
                      filename:
                        type: string
                      height:
                        type: integer
                      id:
                        type: string
                      kind:
                        type: string
                      size_bytes:
                        type: integer
                      url:
                        type: string
                      width:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_files scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/metafield-definitions:
    get:
      summary: List metafield definitions
      operationId: get_metafield_definitions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_metafields
      x-required-scopes: [read_metafields]
      parameters:
        - name: resource_type
          in: query
          required: false
          schema: { type: string }
          description: >-
            Definitions for one kind of resource. An unrecognised value is a 400 naming the
            accepted set rather than an empty page.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Definitions touched at or after this RFC3339 instant. A definition's type,
            validation and VISIBILITY are edited long after it is created, and visibility
            decides whether a value is published at all, so a client that misses one turning
            hidden keeps rendering a field the merchant withdrew.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        description:
                          type: string
                        id:
                          type: string
                        include_in_structured_data:
                          type: boolean
                        key:
                          type: string
                        label:
                          type: string
                        namespace:
                          type: string
                        rank:
                          type: integer
                        resource_type:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                        validation:
                          type: object
                        visible_on_storefront:
                          type: boolean
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Define a custom field
      operationId: post_metafield_definitions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_metafields
      x-required-scopes: [write_metafields]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_type, namespace, key, type]
              additionalProperties: false
              properties:
                resource_type:
                  type: string
                namespace:
                  type: string
                  description: >-
                    Reserved to the application that first writes it. A namespace another
                    application already reserved is refused with 403 namespace_reserved;
                    write under one your app owns. A namespace the MERCHANT created is
                    open to any caller holding the scope.
                key:
                  type: string
                type:
                  type: string
                label:
                  type: string
                description:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      id:
                        type: string
                      include_in_structured_data:
                        type: boolean
                      key:
                        type: string
                      label:
                        type: string
                      namespace:
                        type: string
                      rank:
                        type: integer
                      resource_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      validation:
                        type: object
                      visible_on_storefront:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/metafield-definitions/{definitionId}:
    delete:
      summary: Delete a custom field definition
      operationId: delete_metafield_definitions_by_definitionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_metafields:delete
      x-required-scopes: [write_metafields, write_metafields:delete]
      parameters:
        - name: definitionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                IT REFUSES WHEN THE DEFINITION IS IN USE, with 409 definition_in_use and
                the number of values still stored under it. A definition delete CASCADES:
                removing one erases that field from every product, order and customer that
                carried a value, in a single request whose URL names none of them. Clear
                them with DELETE /api/v1/metafields/{metafieldId} first, so the intent is
                explicit in what you did rather than inferred from one call.
                REQUIRES write_metafields:delete in addition to write_metafields, for the
                same reason: correcting a field's label and erasing the data stored under
                it are different acts, and a merchant must be able to grant the first
                without granting the second.
                A definition that is already gone is a 404 rather than a 204. A fresh
                Idempotency-Key against a missing id means you believe something is there,
                and answering 204 would read as a removal that never happened; a REPLAYED
                key still returns the stored 204, because that retry did land.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_metafields and then on write_metafields:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_metafields, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a metafield definition
      operationId: get_metafield_definitions_by_definitionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_metafields
      x-required-scopes: [read_metafields]
      parameters:
        - name: definitionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      id:
                        type: string
                      include_in_structured_data:
                        type: boolean
                      key:
                        type: string
                      label:
                        type: string
                      namespace:
                        type: string
                      rank:
                        type: integer
                      resource_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      validation:
                        type: object
                      visible_on_storefront:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a custom field definition
      operationId: patch_metafield_definitions_by_definitionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_metafields
      x-required-scopes: [write_metafields]
      parameters:
        - name: definitionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted field is left unchanged. Storefront visibility is NOT settable
                here: a definition created through this API is hidden, and only the
                merchant makes a field visible, in the dashboard where they can see the
                value they would be exposing.
              properties:
                label:
                  type: string
                description:
                  type: string
                rank:
                  type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      description:
                        type: string
                      id:
                        type: string
                      include_in_structured_data:
                        type: boolean
                      key:
                        type: string
                      label:
                        type: string
                      namespace:
                        type: string
                      rank:
                        type: integer
                      resource_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      validation:
                        type: object
                      visible_on_storefront:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/metafields:
    get:
      summary: List metafields
      operationId: get_metafields
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_metafields
      x-required-scopes: [read_metafields]
      parameters:
        - name: resource_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this resource_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: resource_type
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this resource_type. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        definition_id:
                          type: string
                        id:
                          type: string
                        key:
                          type: string
                        namespace:
                          type: string
                        resource_id:
                          type: string
                        resource_type:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                        value:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a custom field's value on a resource
      operationId: put_metafields
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_metafields
      x-required-scopes: [write_metafields]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_type, resource_id, definition_id, value]
              additionalProperties: false
              description: >-
                Upsert by (resource, definition), so replaying the same request converges
                rather than accumulating. Ownership follows the DEFINITION's namespace,
                not anything sent here.
              properties:
                resource_type:
                  type: string
                resource_id:
                  type: string
                definition_id:
                  type: string
                value:
                  description: Shape is governed by the definition's type and validation.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      definition_id:
                        type: string
                      id:
                        type: string
                      key:
                        type: string
                      namespace:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/metafields/{metafieldId}:
    delete:
      summary: Clear a custom field's value on a resource
      operationId: delete_metafields_by_metafieldId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_metafields:delete
      x-required-scopes: [write_metafields, write_metafields:delete]
      parameters:
        - name: metafieldId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The value is
                addressed by the id the read publishes, not by the
                (resource_type, resource_id, definition_id) triple the PUT carries: a
                DELETE taking a body would have a target invisible in the request line
                and in every access log.
                204 whether or not a row was removed, so a client retrying after a
                lost 204 does not read a SUCCESS as a failure; "this field is not set
                on this resource" is the state the caller asked for either way.
                A namespace ANOTHER application reserved is still 403
                namespace_reserved rather than a silent 204: that is not a miss, it is
                a caller with no claim on a row that exists. Removing the DEFINITION
                is not on this API at all, because that would cascade every value of
                the field across the whole catalogue.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_metafields and then on write_metafields:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_metafields, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a metafield
      operationId: get_metafields_by_metafieldId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_metafields
      x-required-scopes: [read_metafields]
      parameters:
        - name: metafieldId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      definition_id:
                        type: string
                      id:
                        type: string
                      key:
                        type: string
                      namespace:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_metafields scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/newsletter-broadcasts:
    get:
      summary: List newsletter broadcasts
      description: >-
        The broadcasts composed for this store, newest first, each with the copy it
        carries.
        NO RECIPIENT LIST IS PUBLISHED. recipient_count is the snapshot of subscribed
        addresses taken when a send started, and 0 on a draft; it is not the current
        subscriber count, so a store that has never sent reads 0 here while holding a
        full list. Read the subscriber list for who is on it.
      operationId: get_newsletter_broadcasts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_newsletter
      x-required-scopes: [read_newsletter]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Broadcasts touched at or after this RFC3339 instant. status, recipient_count and
            sent_count ALL MOVE AFTER THE BROADCAST IS CREATED, and they move while it sends
            rather than when a merchant touches it, so on the default ordering you learn one
            was drafted and never that it went out.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        body:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        recipient_count:
                          type: integer
                        sent_count:
                          type: integer
                        status:
                          type: string
                        subject:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Compose a draft broadcast
      operationId: post_newsletter_broadcasts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_newsletter
      x-required-scopes: [write_newsletter]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              additionalProperties: false
              description: >-
                IT CREATES A DRAFT AND THERE IS NO SEND ROUTE ON THIS API. The
                merchant sends it from their dashboard. A send mails every subscribed
                address at once, spends the store's plan email allowance and cannot be
                recalled, which is not a decision an api key can carry consent for.
                status is refused by name rather than ignored, so an attempt to create
                one already sending is a 400 and not a campaign that never goes out.
              properties:
                subject:
                  type: string
                  description: >-
                    The email subject line. Required and non-blank: a broadcast
                    without one cannot be sent, and finding that out at send time
                    means finding it out from the dashboard.
                body:
                  type: string
                  description: >-
                    The message body, HTML. The platform appends its own unsubscribe
                    footer at send time, so do not add one; a send is refused outright
                    when no unsubscribe mechanism is configured.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      recipient_count:
                        type: integer
                      sent_count:
                        type: integer
                      status:
                        type: string
                      subject:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/newsletter-broadcasts/{broadcastId}:
    get:
      summary: Retrieve a newsletter broadcast
      description: >-
        One broadcast. sent_count against recipient_count is the progress of a send in
        flight, which is what makes polling a "sending" broadcast worthwhile.
      operationId: get_newsletter_broadcasts_by_broadcastId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_newsletter
      x-required-scopes: [read_newsletter]
      parameters:
        - name: broadcastId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      recipient_count:
                        type: integer
                      sent_count:
                        type: integer
                      status:
                        type: string
                      subject:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/newsletter-subscribers:
    delete:
      summary: Erase a subscriber record
      operationId: delete_newsletter_subscribers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_newsletter:delete
      x-required-scopes: [write_newsletter, write_newsletter:delete]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              additionalProperties: false
              description: >-
                ERASURE, NOT UNSUBSCRIBE, and the two are not interchangeable. This
                removes the row entirely, including the record that the address once
                asked to stop, so the next storefront signup adds them back with
                nothing to say they had opted out. Use POST
                /api/v1/newsletter-subscribers/unsubscribe unless you are servicing an
                actual erasure request.
                NEEDS BOTH GRANTS. The qualified scope named above is refused to a key
                holding write_newsletter alone, so ask the merchant for both.
                THE ADDRESS IS IN THE BODY, unusually for a DELETE, and it has to be:
                the idempotency hash covers the method, the path and the body, and the
                path excludes the query string, so ?email= would hash identically for
                two different addresses under one Idempotency-Key and the second call
                would replay the first's 204 having erased nobody.
                IDEMPOTENCY HERE IS PER KEY, NOT PER ADDRESS. Replaying the same
                Idempotency-Key returns the stored 204 untouched. A FRESH key naming an
                address that is already gone is 404 subscriber_not_found, because an
                erasure you believe landed and an erasure that never happened are
                different facts.
              properties:
                email:
                  type: string
                  description: The address to erase. Normalised before lookup.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_newsletter and then on write_newsletter:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_newsletter, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: List newsletter subscribers
      description: >-
        The store's newsletter list: the address, its consent state, where it joined
        and when that state last moved.
        THERE IS NO id FIELD AND THERE WILL NOT BE ONE. The address is the identity:
        the underlying row is keyed on (store, email) and carries no surrogate, so
        key your own records on email. It comes back lowercased and trimmed
        whatever you sent.
        updated_at is what a suppression sync reconciles on. It is the moment the
        consent state moved, so a nightly job reads only the rows that changed
        instead of the whole list.
      operationId: get_newsletter_subscribers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_newsletter
      x-required-scopes: [read_newsletter]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, subscribed, unsubscribed] }
          description: >-
            Filter by consent state. Omit it to receive all three. An unrecognised
            value is a 400 rather than an empty page: a client filtering on "active"
            would otherwise conclude the store has no subscribers at all.
            pending exists only where the store runs double opt-in and those addresses
            receive no broadcasts, so counting them as reachable overstates the list.
        - name: email
          in: query
          required: false
          schema: { type: string }
          description: >-
            EXACT match on one address, normalised the same way stored addresses are,
            so casing and surrounding whitespace do not matter. This is how you ask
            "is this address on the list, and what is its consent state": there is no
            per-address route, because a subscriber has no id and putting an address
            in a path would write it into every proxy and access log verbatim. A
            malformed address is a 400 rather than an empty page.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Subscribers touched at or after this RFC3339 instant. STATUS IS THE FIELD THAT
            MOVES AND IT IS A CONSENT RECORD: a row is written once at sign-up and its status
            is then flipped by every unsubscribe, resubscribe and bounce. On the default
            created_at ordering none of that is visible once you have paged past an address,
            so mailing from a cached list means mailing people who have withdrawn consent.
            THIS IS THE ONE WALK ON THIS API WHERE A MISS HAS A LEGAL CONSEQUENCE rather than
            only a correctness one; run it before every send. Supplying it CHANGES THE ORDER
            of this endpoint: the default walk descends created_at, and this one climbs
            updated_at, which is the only ordering in which a keyset walk over a mutable
            column cannot skip a row. A subscriber whose consent changes mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            THE CURSOR ON THIS LIST CARRIES AN EMAIL ADDRESS in the slot that elsewhere holds
            an id, because a subscriber has no id: treat it as opaque, as you should every
            cursor. The bound is INCLUSIVE, which matters here in particular: a
            list-unsubscribe sweep writes many rows in ONE statement sharing a single instant,
            and an exclusive bound would drop every address in that batch your last page did
            not reach. A cursor belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        source:
                          type: [string, "null"]
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add an address to the newsletter list
      operationId: post_newsletter_subscribers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_newsletter
      x-required-scopes: [write_newsletter]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              additionalProperties: false
              description: >-
                AN UPSERT. An address already on the list is re-armed rather than
                duplicated, so re-running an import is safe and 201 does not mean the
                address was new.
                THE RESPONSE IS DELIBERATELY THIN: the address as stored, and whether
                this store runs double opt-in. It is not the subscriber resource,
                because a write scope does not imply its read on this API and the row
                would say whether the address was already here, when it joined and
                whether it had confirmed. Read GET /api/v1/newsletter-subscribers with
                read_newsletter for that.
                WHEN confirmation_required IS TRUE THE ADDRESS IS NOT REACHABLE YET.
                It is pending, this platform has emailed it a confirmation link, and
                every broadcast skips it until the recipient clicks. Queue a welcome
                series on this response and you are mailing an unconfirmed address.
              properties:
                email:
                  type: string
                  description: >-
                    The subscriber's identity on this list; there is no other key and
                    no id. It is stored lowercased and trimmed, and the response
                    carries the stored form rather than what you sent, so key your own
                    records on what comes back.
                source:
                  type: string
                  description: >-
                    Where the signup came from, free text (a form name, a channel, an
                    import label). Recorded at signup only and never rewritten, so
                    sending it again on a re-subscribe does not change it. Omit it
                    when nothing recorded an origin; the read then serves null.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      confirmation_required:
                        type: boolean
                      email:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/newsletter-subscribers/unsubscribe:
    post:
      summary: Unsubscribe an address on the merchant's authority
      operationId: post_newsletter_subscribers_unsubscribe
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_newsletter
      x-required-scopes: [write_newsletter]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              additionalProperties: false
              description: >-
                THE ROUTE TO USE FOR A SUPPRESSION SYNC. It takes no unsubscribe
                token, unlike the storefront's own unsubscribe, because a token proves
                the address's owner clicked a link in their inbox and an integration
                importing suppressions from another provider has no such proof. Your
                api key is the merchant's authority.
                IT IS NOT A DELETE, and that is the point. The row survives carrying
                status unsubscribed, which is the store's suppression record: the
                storefront signup form checks it. DELETE
                /api/v1/newsletter-subscribers destroys that record and lets the next
                footer signup re-add the same person.
                404 subscriber_not_found when the address was never on this list, so
                you can tell a partial import from a completed one.
              properties:
                email:
                  type: string
                  description: >-
                    The address to suppress. Normalised before lookup, so casing and
                    surrounding whitespace do not matter.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      source:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_newsletter scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notification-channels:
    get:
      summary: List notification channels
      operationId: get_notification_channels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        channel_kind:
                          type: string
                        config:
                          type: object
                          properties:
                            from_number:
                              type: string
                        created_at:
                          type: string
                        has_secret:
                          type: boolean
                        id:
                          type: string
                        is_enabled:
                          type: boolean
                        provider:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notification-channels/{kind}:
    get:
      summary: Retrieve a notification channel
      operationId: get_notification_channels_by_kind
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      parameters:
        - name: kind
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      channel_kind:
                        type: string
                      config:
                        type: object
                        properties:
                          url:
                            type: string
                      created_at:
                        type: string
                      has_secret:
                        type: boolean
                      id:
                        type: string
                      is_enabled:
                        type: boolean
                      provider:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notification-settings:
    get:
      summary: Retrieve notification settings
      operationId: get_notification_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: [string, "null"]
                      from_address:
                        type: string
                      from_name:
                        nullable: true
                      is_active:
                        type: boolean
                      provider:
                        type: string
                      reply_to:
                        nullable: true
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notification-templates:
    get:
      summary: List notification templates
      operationId: get_notification_templates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      parameters:
        - name: event_key
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict the page to the template overriding one event, such as
            order.confirmation. An event this store has not customised returns an
            empty page rather than a 404: the absence of an override is a fact, not
            an error.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Templates touched at or after this RFC3339 instant. A template's BODY is edited
            far more often than templates are created, so the default created_at ordering is
            frozen over exactly the field that moves and a client caching copy keeps sending
            wording the merchant has already rewritten.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        body:
                          type: string
                        channel:
                          type: string
                        created_at:
                          type: string
                        event_key:
                          type: string
                        id:
                          type: string
                        is_active:
                          type: boolean
                        subject:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a notification template
      operationId: post_notification_templates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_key, subject, body]
              additionalProperties: false
              description: >-
                THE COPY GOES LIVE ON THE NEXT SEND of this event, with no review step
                in between. Send is_active false to STAGE it instead: the row exists
                and the event keeps sending the platform default wording until you
                switch it on.
                The subject and body are PARSED before they are stored, so a broken
                construct is a 400 now rather than a send that fails at 2am for every
                order on this event.
                One template per (store, event, channel): an event you have already
                customised is 409 template_exists, and the next move is a PATCH.
              properties:
                event_key:
                  type: string
                  description: >-
                    The send this template overrides, such as order.confirmation or
                    customer.password_reset. IMMUTABLE once created, because every send
                    looks a template up by (store, event, channel); the PATCH refuses
                    this key rather than ignoring it.
                subject:
                  type: string
                  description: >-
                    text/template source for the header line. Placeholders are the
                    event's own payload fields, written {{.order_number}}. A missing
                    placeholder renders empty rather than failing.
                body:
                  type: string
                  description: >-
                    html/template source for the message. Escaped as HTML on render,
                    so a value carrying markup is not a way to inject it.
                is_active:
                  type: boolean
                  description: >-
                    Defaults to true. false stages the copy without using it.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      subject:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notification-templates/{templateId}:
    delete:
      summary: Remove a notification template
      operationId: delete_notification_templates_by_templateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications:delete
      x-required-scopes: [write_notifications, write_notifications:delete]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_notifications and then on write_notifications:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_notifications, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a notification template
      operationId: get_notification_templates_by_templateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      subject:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a notification template
      operationId: patch_notification_templates_by_templateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: templateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value. A
                present but EMPTY subject or body is refused rather than stored: it
                would render a blank message to the shopper. To stop using your own
                wording, set is_active false, which falls the event back to the
                platform default and is reversible.
                event_key is refused BY NAME rather than ignored. A template is looked
                up by (store, event, channel) on every send, so moving it would swap
                two events' copy with nothing to notice; create a template for the
                other event instead.
              properties:
                subject:
                  type: string
                  description: text/template source. Parsed before it is stored.
                body:
                  type: string
                  description: html/template source. Parsed before it is stored.
                is_active:
                  type: boolean
                  description: >-
                    false falls this event back to the platform default wording and is
                    the REVERSIBLE alternative to deleting the template.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      subject:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notifications:
    get:
      summary: List notifications
      operationId: get_notifications
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [bounced, failed, pending, sent, suppressed] }
          description: >-
            Filter by delivery state. sent means the receiving server accepted the
            message, not that a human received it. failed means the row exhausted its
            attempts or was cancelled, not that one attempt errored. bounced means the
            receiving server rejected the address itself. suppressed means the address
            is on the store's suppression list and no send was attempted. An
            unrecognised value is a 400 rather than an empty page: "no message is in
            that state" and "there is no such state" are different facts, and a client
            told the former concludes the store's mail is going out.
        - name: event_key
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict the page to one event, such as order.confirmation, which answers
            "did the receipts go out" without a second route.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attempts:
                          type: integer
                        channel:
                          type: string
                        created_at:
                          type: string
                        event_key:
                          type: string
                        id:
                          type: string
                        last_error:
                          nullable: true
                        max_attempts:
                          type: integer
                        next_attempt_at:
                          nullable: true
                        provider_message_id:
                          type: [string, "null"]
                        status:
                          type: string
                        subject:
                          type: string
                        to_address:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notifications/test:
    post:
      summary: Send a test notification
      operationId: post_notifications_test
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_key, to_address]
              additionalProperties: false
              description: >-
                THIS MAILS A REAL ADDRESS, through the merchant's own relay and under
                the merchant's own domain, every time it is called.
                THE COPY COMES FROM THE STORE'S OWN TEMPLATE for the event and from
                nowhere else. A body carrying subject or body is REFUSED rather than
                ignored: accepting either would make this an open mail relay signed by
                the merchant's domain. Edit the template, then test it.
                201, because it creates a send-log row addressable at GET
                /api/v1/notifications/{notificationId}, and the response is that row.
                400 provider_inactive when the store's sender is configured but not
                enabled: the message would be queued and never delivered, and a
                success there would read as a test that worked.
                USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key answers with
                the first call's row and sends nothing, so a test on a stale key is
                not a test.
              properties:
                event_key:
                  type: string
                  description: >-
                    Which of the store's templates to render, such as
                    order.confirmation.
                to_address:
                  type: string
                  description: Where the rendered message is sent.
                payload:
                  type: object
                  additionalProperties: true
                  description: >-
                    The render context, so a test exercises the same placeholders a
                    real send would. Free-form because every event's context is its own
                    shape; a missing placeholder renders empty rather than failing,
                    which is what makes a test useful for checking wording.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      last_error:
                        nullable: true
                      max_attempts:
                        type: integer
                      next_attempt_at:
                        nullable: true
                      provider_message_id:
                        type: [string, "null"]
                      status:
                        type: string
                      subject:
                        type: string
                      to_address:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notifications/{notificationId}:
    get:
      summary: Retrieve a notification
      operationId: get_notifications_by_notificationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_notifications
      x-required-scopes: [read_notifications]
      parameters:
        - name: notificationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      last_error:
                        nullable: true
                      max_attempts:
                        type: integer
                      next_attempt_at:
                        nullable: true
                      provider_message_id:
                        type: [string, "null"]
                      status:
                        type: string
                      subject:
                        type: string
                      to_address:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notifications/{notificationId}/cancel:
    post:
      summary: Cancel a pending notification
      operationId: post_notifications_by_notificationId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: notificationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The row is marked
                failed so the sweeper stops retrying it. Nothing is removed: the row
                stays in the log with its whole history, which is why this sits under
                write_notifications rather than behind the delete grant.
                409 not_cancelable on a row that has already finished, in either
                direction. A sent message cannot be unsent and a failed one is not
                going anywhere.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      last_error:
                        type: [string, "null"]
                      max_attempts:
                        type: integer
                      next_attempt_at:
                        nullable: true
                      provider_message_id:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      to_address:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/notifications/{notificationId}/retry:
    post:
      summary: Requeue a failed or bounced notification
      operationId: post_notifications_by_notificationId_retry
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: notificationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The row goes back
                to pending with attempts reset and is due immediately; the response is
                that row.
                USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key returns the
                stored response without re-running anything, so a second retry on the
                same key answers with the first result and queues nothing.
                409 not_retryable unless the row is failed or bounced. Those two are
                the requeueable states, because the provider configuration or the
                address may have been fixed since. A pending one is already on its
                way, a sent one would deliver the same message to the shopper twice,
                and a suppressed one is refused because the address is on this
                store's suppression list: remove the suppression, requeueing will
                not do it.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      channel:
                        type: string
                      created_at:
                        type: string
                      event_key:
                        type: string
                      id:
                        type: string
                      last_error:
                        nullable: true
                      max_attempts:
                        type: integer
                      next_attempt_at:
                        type: [string, "null"]
                      provider_message_id:
                        nullable: true
                      status:
                        type: string
                      subject:
                        type: string
                      to_address:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/off-session-charges:
    get:
      summary: List off session charges
      description: >-
        Money taken from a customer's SAVED payment method with nobody present: the
        settlement behind an order edit that raised a total, an exchange that cost more,
        and a post-purchase upsell. Newest first.
        ONE LIST COVERS THE WHOLE WORKLIST. The dashboard has a second endpoint for the
        dunning queue; here that is ?retry_due=true, which selects failed charges whose
        next retry has come due. A client cannot derive that set itself, because the
        comparison is against the server's clock.
        READ status AND next_retry_at TOGETHER. failed does not mean abandoned: a failed
        charge carrying a next_retry_at is still on the schedule (1h, 6h, 24h, then 72h),
        and one carrying null has exhausted it and will never be attempted again.
        idempotency_key is the only link back to the operation that caused the charge,
        because the row carries no order id at all. It is written by the platform and
        names the producer and the object it settles.
        THIS FAMILY IS READ ONLY and there is no write half to add later. Retrying a
        charge presents a real person's stored card to the processor again, with no cart,
        no checkout and nobody present, and the retry schedule exists because issuers
        penalise rapid re-presentment of a decline. That decision stays with a human in
        the dashboard, and apiscope mints no write_off_session_charges at all.
        created_by and saved_payment_method_id are deliberately not published. The first
        is an unbounded actor stamp that would export staff identity, and the second is a
        handle onto a stored card mandate with no resource here to resolve it against.
      operationId: get_off_session_charges
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_off_session_charges
      x-required-scopes: [read_off_session_charges]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, succeeded, failed] }
          description: >-
            The charge state. A value outside this set is a 400 rather than an ignored
            filter, because an unrecognised status matches no row and a reconciliation
            job would read its own typo as a clean ledger off a 200.
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            One shopper's off-session charges. NOT validated against the customer table:
            an id that names nobody returns an empty page rather than a 404, because a
            customer who has never been charged off-session gives the same empty answer.
        - name: retry_due
          in: query
          required: false
          # QUOTED. Unquoted true in a YAML flow sequence resolves to a BOOLEAN, and an
          # enum member whose type does not match the declared type: string is a schema a
          # validator rejects and a generator emits wrongly. The wire value really is the
          # four characters t-r-u-e, since the handler compares the raw query string.
          schema: { type: string, enum: ["true"] }
          description: >-
            true selects failed charges whose next dunning retry has come due, which is
            the worklist the platform is about to act on. Omit it to list every charge;
            false is a 400 rather than a no-op, because it reads two ways (never failed,
            or failed with the retry still ahead) and answering either silently would be
            the wrong answer with no way for a client to tell.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        attempt_count:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: string
                        id:
                          type: string
                        idempotency_key:
                          type: string
                        next_retry_at:
                          type: [string, "null"]
                        provider_ref:
                          nullable: true
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_off_session_charges scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/off-session-charges/{offSessionChargeId}:
    get:
      summary: Retrieve an off session charge
      description: >-
        One off-session charge, in the same shape the list serves, so a row read from a
        page can be re-read on its own rather than by walking the list again. A charge
        belonging to another store is a 404 here exactly as it is invisible in the list.
      operationId: get_off_session_charges_by_offSessionChargeId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_off_session_charges
      x-required-scopes: [read_off_session_charges]
      parameters:
        - name: offSessionChargeId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      attempt_count:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      idempotency_key:
                        type: string
                      next_retry_at:
                        nullable: true
                      provider_ref:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_off_session_charges scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/off-session-charges/{offSessionChargeId}/attempts:
    get:
      summary: List an off session charge's attempts
      description: >-
        Every presentment made for one charge, newest first. The charge alone says
        failed; only this says what the processor answered and when each attempt
        happened, which is the question behind "why has this customer not paid".
        THE ROWS ARE APPEND-ONLY at the database grant, so a published attempt is never
        rewritten or removed. That is what makes this usable as evidence in a dispute.
        error is the processor's own decline text and is a DIAGNOSTIC STRING, not a
        stable machine value: branch on status and show this to a person. It is null on a
        succeeded attempt, as is next_retry_at.
        An unknown charge id is a 404 rather than an empty page, so a typo can never be
        read as "this charge was never attempted".
      operationId: get_off_session_charges_by_offSessionChargeId_attempts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_off_session_charges
      x-required-scopes: [read_off_session_charges]
      parameters:
        - name: offSessionChargeId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attempt_no:
                          type: integer
                        created_at:
                          type: string
                        error:
                          type: [string, "null"]
                        id:
                          type: string
                        next_retry_at:
                          type: [string, "null"]
                        status:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_off_session_charges scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/offline-payment-methods:
    get:
      summary: List offline payment methods
      description: >-
        The non-card tenders this store accepts, with the instructions a shopper is
        shown for each. Four are possible: cod, cheque, bank_transfer and
        trade_credit.
        A TENDER MISSING FROM THIS LIST IS OFF. Checkout treats "never configured"
        and "configured and disabled" alike, so a client deciding what to offer must
        read an absent method as unavailable rather than as an error. A store that
        never opened the settings screen has no rows at all and answers an empty
        page, which is the majority case and not a fault.
        ?enabled=true is what checkout would offer today. It replaces the separate
        storefront read rather than sitting beside it.
      operationId: get_offline_payment_methods
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: enabled
          in: query
          required: false
          schema: { type: string, enum: [true, false] }
          description: >-
            Filter by whether the tender is on offer. Omit it to receive both. An
            unparseable value is a 400 rather than a dropped filter: a client that
            sent enabled=1 and was answered the whole list would offer a shopper
            tenders the merchant had turned off, and nothing in the response would
            say the filter was ignored.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        enabled:
                          type: boolean
                        instructions:
                          type: [string, "null"]
                        method:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/offline-payment-methods/{method}:
    get:
      summary: Retrieve an offline payment method
      description: >-
        One tender's configuration, addressed by name rather than by an id: the
        (store, method) pair is unique and the store comes from your key, so there is
        no surrogate id on this API at all.
        404 MEANS THE MERCHANT NEVER CONFIGURED IT, which is not the same fact as
        enabled false. Only one of those two is a decision, and a client reporting a
        merchant's setup needs to tell them apart.
      operationId: get_offline_payment_methods_by_method
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: method
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      enabled:
                        type: boolean
                      instructions:
                        type: [string, "null"]
                      method:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Configure an offline payment method
      description: >-
        Turns a tender on or off and sets the directions a shopper is shown for it.
        THIS IS A LIVE CHECKOUT CHANGE. The cart reads the same row on the next
        request, so an enabled tender appears at checkout immediately with whatever
        instructions this call stored. There is no draft and no publish step.
        A FULL REPLACE, which is why it is PUT and not PATCH: the statement
        underneath writes both columns every time, so an omitted instructions CLEARS
        the stored text rather than leaving it alone.
        200 on both the first write and every later one. The status never tells you
        whether the tender had been configured before, because create and update are
        the same upsert on a row you already know the address of.
      operationId: put_offline_payment_methods_by_method
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_offline_payments
      x-required-scopes: [write_offline_payments]
      parameters:
        - name: method
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              additionalProperties: false
              description: >-
                A FULL REPLACE, not a merge, which is why this is PUT and not PATCH.
                The statement underneath writes both columns every time, so an
                omitted instructions is a CLEARED field and not an unchanged one.
                Read the tender first and send back the document you received.
                THE METHOD IS THE PATH SEGMENT and one of cod, cheque, bank_transfer
                or trade_credit. Anything else is a 400 naming the four, not a 404: a
                value outside that set is a typo in a closed enum rather than a
                resource that might exist later. Sending method in the body is
                refused, because two spellings of one key is how a write lands on the
                wrong tender.
                THE STOREFRONT CHANGES IMMEDIATELY. Checkout reads this row on the
                next request, so there is no draft, no preview and no publish step,
                and this family emits no webhook topic: a client reconciling has to
                poll.
                IT CANNOT SET CREDIT TERMS. credit_limit is refused by name here and
                no route on this API accepts it under any grant; extending credit to
                a named buyer is a decision a merchant makes in their dashboard.
                LAST WRITE WINS. There is no If-Match and no version, so replaying an
                old body after a newer write reverts the row.
              properties:
                enabled:
                  type: boolean
                  description: >-
                    REQUIRED, and the whole decision this route exists to make.
                    Defaulting it either way would turn a typo into a live checkout
                    change: false silently removes a tender a client only meant to
                    re-word, true puts one on offer nobody asked for. False is also
                    the reversible form of removing a tender, and it is what this
                    family publishes instead of a DELETE.
                instructions:
                  type: [string, "null"]
                  maxLength: 4000
                  description: >-
                    The payment directions the shopper is shown at checkout: where to
                    wire the money, who to make the cheque out to. Send null or omit
                    the key to clear them. Longer than the limit is REFUSED rather
                    than truncated, because a silently shortened bank account number
                    is discovered by a shopper rather than by the client that sent
                    it. It is merchant-authored PUBLIC text, not a credential: every
                    shopper reaching checkout already sees it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      enabled:
                        type: boolean
                      instructions:
                        type: [string, "null"]
                      method:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store:
    get:
      summary: Retrieve online store
      description: >-
        The storefront builder configuration: the working draft, the published copy
        the storefront actually renders, and the version every write has to echo.
        ONE DOCUMENT PER STORE. There is no id and no list; the store comes from your
        key. A store that has never opened the builder answers 200 with an empty
        draft, a null published and a NULL updated_at rather than 404, and that null
        is what a first write sends as expected_updated_at.
        draft and published are OPAQUE JSON DOCUMENTS owned by the builder rather than
        by this API. Their internal shape is deliberately left undescribed here and
        changes with builder releases: read one, change what you mean to change, and
        send the whole thing back.
        published and published_at are NULL until the first publish, which is a
        different fact from an empty published document. Empty would mean the live
        storefront renders nothing.
      operationId: get_online_store
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      draft:
                        type: object
                      published:
                        type: object
                      published_at:
                        type: [string, "null"]
                      schema_version:
                        type: integer
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store/draft:
    put:
      summary: Replace the storefront builder draft
      description: >-
        Replaces the working draft. The live storefront is untouched until you
        publish.
        THE WHOLE DOCUMENT REPLACES THE STORED ONE. There is no merge and no partial
        update: read GET /api/v1/online-store, change what you mean to change, and
        send the result back.
        expected_updated_at IS REQUIRED. This document is a singleton, so you, the
        merchant's dashboard and every other integration are writers on one row, and a
        write naming no version would overwrite work nobody knows was lost. Send null
        only when you believe the store has no configuration at all; otherwise send
        the updated_at you last read. A mismatch is 409 config_modified, and the fix is
        always the same: re-read, re-apply your change, re-send.
      operationId: put_online_store_draft
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schema_version, draft, expected_updated_at]
              additionalProperties: false
              description: >-
                ALL THREE KEYS ARE REQUIRED and none is defaulted. Defaulting
                schema_version would store your document under a format it was not
                written for, and defaulting expected_updated_at would make the unsafe
                write the easy one.
                published, published_at, updated_at, store_id and published_by are
                refused BY NAME rather than ignored, so a client that tried to change
                the live storefront directly is told instead of being answered 200
                while nothing happened.
              properties:
                schema_version:
                  type: integer
                  description: >-
                    The builder document format. A version this backend does not know
                    is a 400 rather than a document stored and rendered as nothing.
                draft:
                  type: object
                  description: >-
                    The whole builder document, which REPLACES the stored draft. Nothing is
                    merged: read GET /api/v1/online-store, edit, and send it all back.
                    ONE FIELD OF IT IS VALIDATED and the rest is opaque, held only to being a
                    JSON object within the 512 KB bound.
                    global.custom_font_id is checked against the store's uploaded font
                    families. A family that EXISTS but is missing the 400 or the 700 weight in
                    the normal style is refused with 400 invalid_body naming
                    draft.global.custom_font_id, because a one-weight family fails nowhere: the
                    browser synthesises the missing bold and every heading in the store renders
                    in a smeared copy of the regular face.
                    AN ID THAT RESOLVES TO NOTHING IS ACCEPTED, whether the family was deleted
                    or belongs to another store. Such a document renders the Google family
                    rather than a wrong typeface, and refusing it would lock a merchant out of
                    editing the very draft that names it.
                    If the font library cannot be read at all the write is refused with 503
                    font_check_unavailable rather than stored unchecked, because a check that
                    could not run is not a check that passed. Retry shortly.
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. NULL asserts that this store has no
                    configuration at all, which is the only way a first write can name
                    a version that does not exist; it is refused the moment a row does
                    exist, so it cannot be used to skip the check. A mismatch is 409
                    config_modified.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      draft:
                        type: object
                      published:
                        nullable: true
                      published_at:
                        nullable: true
                      schema_version:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store/publish:
    post:
      summary: Publish the stored draft to the live storefront
      description: >-
        Makes the STORED draft live and records it in the publish history.
        It publishes what the server already holds, never a document in this request:
        save one with PUT /api/v1/online-store/draft first, then publish the
        updated_at that write returned. Sending a draft here is a 400.
        THE PRECONDITION MATTERS MORE HERE THAN ANYWHERE ELSE IN THIS FAMILY. A stale
        draft save loses one edit; a stale publish makes a document you never saw the
        one every shopper renders, and answers 200 as though it were yours.
        RETRIES: replaying the same Idempotency-Key returns the recorded response and
        publishes nothing. A FRESH key after a publish that already succeeded is 409
        config_modified, because that publish moved updated_at.
      operationId: post_online_store_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [expected_updated_at]
              additionalProperties: false
              description: >-
                The only input is the version you expect. A draft or a schema_version
                sent here is refused by name: this route promotes the document the
                server already holds, and accepting one in the body would put a tree
                that never passed validation in front of every shopper.
              properties:
                expected_updated_at:
                  type: string
                  description: >-
                    The updated_at you last read, and NOT nullable here: there is
                    nothing to publish before the first draft save, so null would be a
                    precondition that can never hold. A mismatch is 409
                    config_modified and means the draft moved under you.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      draft:
                        type: object
                      published:
                        type: object
                      published_at:
                        type: [string, "null"]
                      schema_version:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store/revisions:
    get:
      summary: List online store revisions
      description: >-
        The publish history, newest first: one entry per publish.
        HEADERS ONLY. A stored configuration can reach 512 KB, so a page carrying
        every snapshot would be tens of megabytes for a caller asking what happened.
        Fetch a single revision when you want the document it captured.
        No actor is published. The column behind it holds either a dashboard user id
        or an api key id, and neither resolves to anything you can look up.
      operationId: get_online_store_revisions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        schema_version:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store/revisions/{revisionId}:
    get:
      summary: Retrieve an online store revision
      description: >-
        One revision WITH the configuration it captured, under "config".
        This is the non-destructive half of a rollback: read a past design and compare
        it against the live one without restoring it over whatever is in the draft.
        The snapshot is served as it was captured, including its own schema_version,
        which may be older than the store's current one.
        A revision belonging to another store is 404, not 403.
      operationId: get_online_store_revisions_by_revisionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      parameters:
        - name: revisionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      config:
                        type: object
                      created_at:
                        type: string
                      id:
                        type: string
                      schema_version:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/online-store/revisions/{revisionId}/restore:
    post:
      summary: Restore a past revision into the draft
      description: >-
        Loads a past snapshot back into the DRAFT. It does not make it live.
        The response is a configuration whose draft changed and whose published did
        not, so review it and publish when you are ready. A one-call rollback straight
        to live would change what every shopper sees with nobody having looked at it.
        It OVERWRITES the draft, which is why it carries the same expected_updated_at
        a draft save does: without one it would discard another integration's unsaved
        work while naming no document at all.
      operationId: post_online_store_revisions_by_revisionId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: revisionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [expected_updated_at]
              additionalProperties: false
              description: >-
                The revision is named in the PATH, so revision_id in the body is
                refused; so is config, because a restore copies the stored snapshot
                and is not a way to write a document of your own.
              properties:
                expected_updated_at:
                  type: string
                  description: >-
                    The updated_at you last read. Required and not nullable: a restore
                    OVERWRITES the draft, so without it this call would discard another
                    integration's unsaved work while naming no document at all.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      draft:
                        type: object
                      published:
                        type: object
                      published_at:
                        type: [string, "null"]
                      schema_version:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims:
    get:
      summary: List order claims
      operationId: get_order_claims
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: The claims raised against one order.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Claims in one state. An unrecognised value is a 400 naming the accepted set rather
            than an empty page.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Claims touched at or after this RFC3339 instant. A CLAIM IS A WORKFLOW and its
            status is the whole resource: it moves from raised to resolved or refused entirely
            after creation, so the default ordering is frozen at the least interesting moment
            in its life.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        canceled_at:
                          nullable: true
                        created_at:
                          type: string
                        customer_initiated:
                          type: boolean
                        id:
                          type: string
                        images:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              url:
                                type: string
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              is_additional_item:
                                type: boolean
                              order_line_item_id:
                                type: string
                              quantity:
                                type: integer
                              reason:
                                type: string
                        order_id:
                          type: string
                        resolved_at:
                          nullable: true
                        status:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Open a claim against an order
      operationId: post_order_claims
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id, type, items]
              additionalProperties: false
              description: >-
                Opens a claim against a placed order. It lands as 'requested', exactly like one
                a shopper raised, so a merchant still approves it: a key that could open an
                already-approved claim would be the approval gate defeated by the create route,
                and on this family approval is the step before money moves. customer_initiated
                is recorded false, because a key is the merchant's own integration rather than
                the shopper, and no acting person is recorded at all.
                THE SETTLEMENT FIGURE IS COMPUTED HERE AND IS NEVER YOURS TO SEND. It is the
                claimed lines valued at what the shopper actually paid per unit, an integer in
                the MINOR UNITS of the order's currency, then scaled down in proportion to the
                order's own discounts, gift cards, store credit and loyalty. refund_amount and
                refund_amount_minor are refused by name, and the claim resource publishes no
                money and no currency at all, so what a later resolve actually paid is read from
                the order under read_orders rather than from here.
                THE STORE'S CLAIM WINDOW IS NOT APPLIED. That policy governs the storefront
                door, where a shopper raises a claim on their own order; a claim opened with an
                api key may name an order older than the window.
                THERE IS NO CONVERGENCE GUARANTEE OUTSIDE THE IDEMPOTENCY WINDOW. A claim has no
                unique column, so the same body replayed later opens a SECOND claim over the
                same lines, or answers 409 quantity_exceeds_ordered once the first has consumed
                them. Reconcile by listing GET /api/v1/order-claims?order_id=, not by re-posting.
                An order_id naming no order in this store answers 404 not_found rather than 400,
                even though it is a body field.
                NO FIELD HERE MAY BE SENT AS null. The nullable set on this body is empty, so an
                explicit null answers 400 naming the field; omit the key instead.
                ALSO REFUSED BY NAME: reason and items[].note, because that column carries the
                shopper's own words through one door and merchant prose about that shopper
                through the other and the read publishes neither, so text sent here could never
                be read back through any route; no_notification, because whether the shopper is
                emailed is a staff decision made where the person making it can see the
                customer, and a claim opened here notifies on every transition; status and
                customer_initiated, because the platform sets both; and the provenance keys (id,
                store_id, created_by, requested_at, resolved_at, canceled_at, created_at,
                updated_at).
              properties:
                order_id:
                  type: string
                  description: >-
                    Required. The placed order this claim is raised against, and immutable:
                    nothing in this family moves a claim to another order, because the claimed
                    lines are that order's lines. It sits in the body rather than the path
                    because the resource and its four transitions live under /order-claims, and
                    a create one segment away under a different prefix would split one resource
                    across two.
                type:
                  type: string
                  enum: [refund, replace]
                  description: >-
                    Required, with NO default, because a claim settled in money and one settled
                    by shipping a replacement are different promises to the shopper. It is fixed
                    at creation and no transition changes it, and it is what decides whether
                    POST /api/v1/order-claims/{claimId}/resolve moves money or triggers a
                    replacement. A value outside the two answers 400.
                items:
                  type: array
                  minItems: 1
                  maxItems: 200
                  description: >-
                    The claimed lines, in the order they are stored and served in, so a client
                    that reorders its request reorders the resource. More than 200 answers 400
                    invalid_quantity rather than a code of its own.
                  items:
                    type: object
                    required: [order_line_item_id, quantity]
                    additionalProperties: false
                    properties:
                      order_line_item_id:
                        type: string
                        description: >-
                          A line of the order named by order_id. One from another order answers
                          400 line_not_in_order rather than being dropped in silence. It is NOT
                          a key within the claim: the same line may appear twice, once as the
                          line being claimed and once as the replacement being sent, which is
                          why every published item carries an id of its own.
                      quantity:
                        type: integer
                        minimum: 1
                        maximum: 1000000
                        description: >-
                          Required on every item, with no default. Omitting it cannot mean the
                          rest of the line, because that would make one body claim different
                          amounts at different times. An explicit 0 is refused rather than read
                          as absent. Counting the claims already raised against the same line,
                          exceeding what is left to claim answers 409 quantity_exceeds_ordered;
                          a rejected or cancelled claim releases its share back.
                      reason:
                        type: string
                        enum: [missing_item, wrong_item, production_failure, other]
                        description: >-
                          Optional, defaulting to other, which is the column's own default.
                          Accepted here unlike the same key on a v1 return line, and the
                          difference is real rather than an inconsistency: this one is a bounded
                          enum the read publishes, while the free-text note beside it is
                          refused. A value outside the set answers 400.
                      is_additional_item:
                        type: boolean
                        description: >-
                          Optional, defaulting to false. false is a line the customer is
                          claiming against: it consumes that order line's remaining claimable
                          quantity and ADDS to the settlement figure. true is a replacement the
                          merchant is sending out: it consumes nothing and adds nothing.
                images:
                  type: array
                  maxItems: 20
                  description: >-
                    Optional evidence photos, in the order they are stored and served in. An
                    empty or omitted list is the ordinary case, since an integration opening a
                    claim from a support ticket usually has no photo. More than 20 answers 400.
                  items:
                    type: string
                    description: >-
                      An https url to a public host, validated when it is written: an ip literal
                      that is loopback, private, link-local, carrier-NAT or unspecified answers
                      400, so a claim cannot be used to point staff screens at internal
                      infrastructure.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims/{claimId}:
    get:
      summary: Retrieve an order claim
      operationId: get_order_claims_by_claimId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: claimId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims/{claimId}/approve:
    post:
      summary: Approve a requested order claim
      operationId: post_order_claims_by_claimId_approve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: claimId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Moves a requested claim to
                approved, which is the merchant agreeing to settle and nothing more: NO MONEY
                MOVES HERE. The payment happens on
                POST /api/v1/order-claims/{claimId}/resolve and nowhere else, so approving a
                refund-type claim on an order with no captured payment SUCCEEDS and the refusal
                arrives later, at resolve, as 409 not_refundable.
                LEGAL ONLY FROM requested. An approved, resolved, rejected or cancelled claim
                answers 409 invalid_transition, so this is not idempotent by shape: a replayed
                Idempotency-Key answers the stored response, and a FRESH key on an
                already-approved claim answers 409 rather than 200.
                IT EMAILS THE SHOPPER. A claim opened through this API carries no_notification
                false and cannot set it, so every transition on it mails the customer; a claim a
                staff member opened in the dashboard with notifications suppressed stays
                suppressed here. The mail is best effort: a failure is logged and never
                returned, so a 200 is not evidence the message was delivered.
                There is no approved_at column, so the only stamp this moves is updated_at.
                THE BODY IS READ ONLY TO REFUSE IT. refund_amount and refund_amount_minor are
                refused BY NAME, because the settlement figure was computed when the claim was
                opened and this grant chooses whether to settle, never for how much; a client
                that sent one would otherwise read the 200 as having chosen it. status is
                refused because the route in the url is the transition and only one of the two
                could win, and reason because a transition carries no note of its own.
                The response is the WHOLE claim in the shape
                GET /api/v1/order-claims/{claimId} serves, so treat it as a fresh read rather
                than following it with a GET.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims/{claimId}/cancel:
    post:
      summary: Cancel an order claim
      operationId: post_order_claims_by_claimId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: claimId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. THIS FAMILY'S DELETE: no
                DELETE route exists and none will, because cancelling leaves the row, its lines,
                its evidence images and its stamps exactly where a chargeback dispute six months
                later needs to find them. canceled_at is stamped the first time the claim enters
                cancelled and is never rewritten.
                LEGAL FROM requested AND approved, so it is what withdraws an approval before it
                is paid. From resolved it answers 409 invalid_transition and there is no
                un-resolve: the money has already left, and the honest reversal of an
                over-refund is a new refund against the order rather than a status flip here. A
                second cancel, and a cancel of a rejected claim, are 409 as well.
                NO MONEY MOVES and no stock moves. Cancelling RELEASES the claimed quantity back
                to the order lines, so those units become claimable again and a fresh claim may
                cover them.
                IT EMAILS THE SHOPPER, on the same terms as the approve route: a claim opened
                through this API cannot suppress the mail, and the mail is best effort, so a 200
                is not evidence of delivery.
                THE BODY IS READ ONLY TO REFUSE IT. refund_amount and refund_amount_minor are
                refused BY NAME because no figure is settled here and none is the caller's to
                name, status because the route in the url is the transition, and reason because
                a transition carries no note of its own.
                The response is the WHOLE claim in the shape
                GET /api/v1/order-claims/{claimId} serves.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        type: string
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims/{claimId}/reject:
    post:
      summary: Reject a requested order claim
      operationId: post_order_claims_by_claimId_reject
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: claimId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Refuses the claim outright,
                and it is LEGAL ONLY FROM requested. An APPROVED claim cannot be rejected: that
                answers 409 invalid_transition, and cancel is what withdraws an approval. The
                asymmetry is deliberate rather than an oversight, because reject records the
                merchant refusing the claim and cancel records it being called off, and a
                merchant reporting on disputes has to tell the two apart.
                TERMINAL AND NOT REVERSIBLE. No route returns a claim to requested, so a
                rejection cannot be undone through this API. It does RELEASE the claimed
                quantity back to the order lines, so those units become claimable again and the
                repair for a rejection sent in error is a new claim over the same lines rather
                than a second transition on this one.
                NO MONEY MOVES and no stock moves. There is no rejected_at column, so the only
                stamp this moves is updated_at, and a client tells a rejection from a
                cancellation by the status rather than by a timestamp.
                IT EMAILS THE SHOPPER, on the same terms as the approve route: a claim opened
                through this API cannot suppress the mail, and the mail is best effort, so a 200
                is not evidence of delivery.
                THE BODY IS READ ONLY TO REFUSE IT. reason is the key an integrator reaches for
                first here and it is refused BY NAME, because a transition carries no note of
                its own and the claim's own reason column is published from neither door, so a
                rejection note sent here could never be read back. refund_amount,
                refund_amount_minor and status are refused too.
                The response is the WHOLE claim in the shape
                GET /api/v1/order-claims/{claimId} serves.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        nullable: true
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-claims/{claimId}/resolve:
    post:
      summary: Settle an approved order claim
      operationId: post_order_claims_by_claimId_resolve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: claimId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. THIS IS THE CALL THAT MOVES
                REAL MONEY, and it is LEGAL ONLY FROM approved.
                ON A refund-TYPE CLAIM it reverses the captured charge for the figure computed
                when the claim was OPENED, an integer in the MINOR UNITS of the order's
                currency, split at settle time: the card takes back only what it actually
                charged (the order total less what gift cards, store credit and loyalty paid),
                and the tender-funded remainder is returned to the customer as store credit. The
                amount is never the caller's to choose, which is what keeps a settlement on the
                same grant as the rest of this family: the token decides WHETHER to settle and
                never HOW MUCH. On an order with no customer record the tender-funded remainder
                cannot be credited to anybody, so it is recorded in the store's audit log rather
                than paid, and the claim still resolves.
                ON A replace-TYPE CLAIM no money moves at all; the replacement fulfillment is
                what is triggered instead.
                THE MONEY MOVES BEFORE THE STATUS FLIPS, so a failed settlement leaves the claim
                APPROVED and returns the failure rather than a 500: 409 not_refundable when the
                order has no captured payment to reverse, and 409 refund_exceeds_charge when
                earlier refunds against it have already taken part of the charge. Read the claim
                back to see where it ended up.
                WHAT A RETRY DOES. Replaying the SAME Idempotency-Key answers the stored
                response and re-runs nothing, so one key cannot settle twice. A FRESH key on a
                claim that already resolved answers 409 invalid_transition under the same row
                lock rather than paying again. A fresh key after a settlement that moved money
                but lost the status flip is safe as well: the card refund is keyed on this claim
                id and the store credit on its own claim reference, so the provider is not
                charged a second time and the retry only finishes the transition.
                resolved_at is stamped the first time the claim enters resolved. The claim
                resource publishes no money and no currency, so what was actually paid is read
                from the order under read_orders rather than from here.
                IT EMAILS THE SHOPPER, on the same terms as the approve route: a claim opened
                through this API cannot suppress the mail, and the mail is best effort, so a 200
                is not evidence of delivery.
                THE BODY IS READ ONLY TO REFUSE IT, and refund_amount and refund_amount_minor
                are refused BY NAME here above all: a client that sent a figure would otherwise
                get a plain resolve answering 200 and would believe it had chosen how much the
                shopper was paid. status and reason are refused too.
                The response is the WHOLE claim in the shape
                GET /api/v1/order-claims/{claimId} serves.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      customer_initiated:
                        type: boolean
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            url:
                              type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            is_additional_item:
                              type: boolean
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                            reason:
                              type: string
                      order_id:
                        type: string
                      resolved_at:
                        type: string
                      status:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits:
    get:
      summary: List order edits
      operationId: get_order_edits
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_order_edits
      x-required-scopes: [read_order_edits]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict the page to edits staged against one order. Omit it to read the
            store's edits newest first.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [created, requested, confirmed, declined, cancelled] }
          description: >-
            Filter by edit state. Only created and requested are ACTIVE; the other
            three are terminal. An unrecognised value is a 400 rather than an empty
            page, so a typo cannot silently report that no edits were cancelled.
            Note for clients written before 001145: this table's terminal value used
            to be spelled "canceled", and that spelling is now rejected.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        diff_total_minor:
                          type: integer
                        id:
                          type: string
                        order_id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Open an order edit
      operationId: post_order_edits
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id]
              additionalProperties: false
              description: >-
                Opens an EMPTY change set against a placed order: status created,
                diff_total_minor 0, nothing staged. An order may have only one open
                edit, so a second create answers 409 rather than splitting one
                amendment across two change sets; fetch the existing edit instead of
                retrying.
                NOTHING STAGED THROUGH THIS API IS EVER APPLIED BY IT. Confirming an
                edit charges the shopper's stored card off-session, refunds, mints
                store credit and rewrites the order, so it is not published: a
                merchant confirms in the dashboard.
              properties:
                order_id:
                  type: string
                  description: The placed order this edit amends.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}:
    get:
      summary: Retrieve an order edit
      operationId: get_order_edits_by_editId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_order_edits
      x-required-scopes: [read_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/cancel:
    post:
      summary: Abandon an order edit
      operationId: post_order_edits_by_editId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The merchant
                withdrawing their own proposal: the staged changes are discarded and
                the edit becomes terminal, which frees the order for a new edit.
                Nothing was applied, so nothing is undone and no money moves.
                NOT idempotent: a second cancel answers 409 invalid_transition, as
                does a cancel on an already-declined edit. The retry window is covered
                by the idempotency ledger every write on this API runs behind.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/decline:
    post:
      summary: Decline an order edit
      operationId: post_order_edits_by_editId_decline
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The counterpart to
                cancel, kept separate because the two record different facts a
                merchant reporting on amendments has to tell apart: cancel is the
                proposal being withdrawn, decline is it being refused. Both are
                terminal and neither moves money.
                Answers 409 invalid_transition on an edit that is already terminal.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/items:
    post:
      summary: Stage adding a variant to an order
      operationId: post_order_edits_by_editId_items
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, quantity]
              additionalProperties: false
              description: >-
                An UPSERT keyed on the variant, so re-sending the same variant
                REPLACES the staged quantity rather than stacking a second row and a
                replay converges. 200 rather than 201 for that reason: the call
                cannot say whether it created a staged change or replaced one.
                The unit price is snapshotted from the variant's catalogue price and
                is not settable.
              properties:
                variant_id:
                  type: string
                quantity:
                  type: integer
                  description: >-
                    ABSOLUTE, never a delta. Sending 3 twice leaves three units
                    staged, not six. Must be positive.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/items/{lineItemId}:
    patch:
      summary: Stage a new quantity for an existing order line
      operationId: patch_order_edits_by_editId_items_by_lineItemId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
        - name: lineItemId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [quantity]
              additionalProperties: false
              description: >-
                The path names an EXISTING order line, the id /api/v1/orders already
                publishes, not a staged change. The only writable property is the
                quantity, which is why this is a PATCH: a PUT would promise the whole
                staged change is being replaced, and neither the action nor the
                snapshotted price is client-writable.
              properties:
                quantity:
                  type: integer
                  description: >-
                    ABSOLUTE, never a delta, so replaying the same body converges.
                    Removing the line entirely is POST
                    /order-edits/{editId}/items/{lineItemId}/remove.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/items/{lineItemId}/remove:
    post:
      summary: Stage removing a line from an order
      operationId: post_order_edits_by_editId_items_by_lineItemId_remove
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_order_edits
      x-required-scopes: [write_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
        - name: lineItemId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. NOT a DELETE, and
                deliberately so: this INSERTS a staged change carrying action=remove
                and answers with the refreshed edit, so a DELETE here would promise
                destruction and then return a body. The addressed line is still on the
                order, and stays there until a merchant confirms the edit in the
                dashboard.
                Removes the WHOLE line. Reducing it is PATCH
                /order-edits/{editId}/items/{lineItemId}.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      order_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-edits/{editId}/preview:
    get:
      summary: Retrieve order edit preview
      operationId: get_order_edits_by_editId_preview
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_order_edits
      x-required-scopes: [read_order_edits]
      parameters:
        - name: editId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      currency_code:
                        type: string
                      diff_total_minor:
                        type: integer
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            action:
                              type: string
                            id:
                              type: string
                            order_line_item_id:
                              type: [string, "null"]
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              nullable: true
                      new_subtotal_minor:
                        type: integer
                      new_tax_total_minor:
                        type: integer
                      new_total_minor:
                        type: integer
                      order_id:
                        type: string
                      original_total_minor:
                        type: integer
                      payment_action:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_order_edits scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-margins:
    get:
      summary: List order margins
      description: >-
        What each order actually MADE, newest first: revenue, the real cost of the goods, the
        processor fee, the carrier cost, the tax, and anything refunded since.
        THESE FIGURES ARE FROZEN AT PLACEMENT and never recomputed. The cost of goods is each
        line's variant cost price AT THE INSTANT THE ORDER WAS PLACED, so editing a cost price
        today does not restate last month. That is the whole design, and it is why this family
        has no write half: nothing may rewrite a frozen row, including you.
        EVERY ROW CARRIES ITS OWN currency_code AND YOU MUST GROUP BY IT. Amounts are minor
        units of the currency the shopper paid in, so a store selling into several regions
        returns several currencies in one page and adding them produces a number that is wrong
        with nothing reporting it.
        READ cogs_source BEFORE YOU READ cogs. actual means every line had a cost price.
        partial means some did, so cogs is a FLOOR and net_profit a CEILING, and
        missing_cost_variants says how many products are unpriced. backfilled means the figures
        were written after the fact by a backfill rather than at placement. missing means none
        of the lines had a cost, and cogs, net_profit and margin_bps are then NULL rather than
        zero, because a zero cost would make an unpriced catalogue read as a perfect margin.
        Use ?cogs_source=missing to get exactly the orders you still need to price.
        payment_fee AND shipping_cost MAY BE MODELED OR OBSERVED, and fee_source and
        shipping_source say which, per row. modeled means computed from the merchant's cost
        model at placement, and is not the fee a processor later settled or the amount a
        carrier later billed. actual means a real captured figure was found and used instead.
        Read the source field rather than assuming either: summing the two kinds together is
        fine, but treating a modeled figure as invoiced is not.
        refunded IS THE ONE FIGURE THAT MOVES. It sums SUCCEEDED refunds at read time, so it
        can grow on a row you already hold and net_profit falls with it. Refunds still pending
        are not counted, because one that later fails must not lower a profit you already
        reported.
        ORDERS PLACED BEFORE MARGIN TRACKING BEGAN ARE ABSENT, with no placeholder row. The
        cost prices in force at that instant are gone, so there is nothing honest to
        synthesise, and you will find fewer margins than orders. That gap is the coverage
        window rather than an error.
        PAGED BY computed_at, which the freeze never rewrites, so "everything since my last
        sync" is a walk that stops when it crosses your watermark. A refund on an old order
        does not move computed_at, so such a walk finds new orders rather than new refunds.
        THERE IS NO PERIOD REPORT ON THIS API and there will not be one. A total over a window
        is not a resource: its shape would be a chart's shape, and the merchant-facing one
        covers a single currency and silently omits every order in every other. Total these
        rows yourself and you get complete data you can group correctly, plus a per-order flag
        telling you which numbers not to trust.
      operationId: get_order_margins
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_margin
      x-required-scopes: [read_margin]
      parameters:
        - name: cogs_source
          in: query
          required: false
          schema: { type: string, enum: [actual, partial, backfilled, missing] }
          description: >-
            Narrow the page by how trustworthy the cost figure is.
            ?cogs_source=missing is the one to integrate against: those are exactly the
            orders whose products had no cost price at placement, so their cogs,
            net_profit and margin_bps are null and their profit is unknowable until you
            price the catalogue. partial means the cost understates and
            missing_cost_variants says by how many products.
            A value outside the enum is a 400 rather than an unfiltered page, because a
            silently ignored filter would hand you every order you have ever taken when
            you asked which ones are still unpriced, and you would read your whole
            catalogue as unpriced.
        - name: currency_code
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow the page to one currency, which is what totalling these rows requires:
            every amount is minor units of the order's OWN currency, so a multi-region
            store returns several currencies in one page. CASE IS FOLDED on both sides, so
            "USD" and "usd" match the same rows. A currency the store has never sold in is
            an empty page rather than a 404, which is what stops this being a probe for
            which markets a merchant trades in.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        cogs:
                          type: [integer, "null"]
                        cogs_source:
                          type: string
                        computed_at:
                          type: string
                        currency_code:
                          type: string
                        fee_source:
                          type: string
                        margin_bps:
                          type: [integer, "null"]
                        missing_cost_variants:
                          type: integer
                        net_profit:
                          type: [integer, "null"]
                        order_id:
                          type: string
                        payment_fee:
                          type: integer
                        refunded:
                          type: integer
                        revenue:
                          type: integer
                        shipping_cost:
                          type: integer
                        shipping_source:
                          type: string
                        tax:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_margin scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/order-margins/{orderId}:
    get:
      summary: Retrieve an order margin
      description: >-
        What one order made, in the same shape the list serves, so a row read from a page can
        be re-read on its own rather than by walking the collection again. That is how a client
        picks up a refund issued since it last synced.
        THE PATH TAKES THE ORDER ID, which is this resource's own id: there is one frozen
        margin per order forever, so the order's id is the only handle it has and no separate
        margin id exists. It is the same value the orders family hands you.
        A 404 HERE IS margin_not_available AND IT DOES NOT MEAN "no such order". An order
        placed before margin tracking began and an id that names nothing answer identically on
        purpose, because telling them apart would mean resolving the order and turning this
        grant into a probe for which order ids exist in the store. Do not go looking for a bug
        in your order sync when you meet it.
      operationId: get_order_margins_by_orderId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_margin
      x-required-scopes: [read_margin]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cogs:
                        type: [integer, "null"]
                      cogs_source:
                        type: string
                      computed_at:
                        type: string
                      currency_code:
                        type: string
                      fee_source:
                        type: string
                      margin_bps:
                        type: [integer, "null"]
                      missing_cost_variants:
                        type: integer
                      net_profit:
                        type: [integer, "null"]
                      order_id:
                        type: string
                      payment_fee:
                        type: integer
                      refunded:
                        type: integer
                      revenue:
                        type: integer
                      shipping_cost:
                        type: integer
                      shipping_source:
                        type: string
                      tax:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_margin scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders:
    get:
      summary: List orders
      operationId: get_orders
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_orders
      x-required-scopes: [read_orders]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Orders in one state. An unrecognised value is a 400 naming the accepted set,
            not an empty page: a typo would otherwise be indistinguishable from a store
            with no orders in that state.
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: One customer's orders.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Orders touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the whole list. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, and this one climbs updated_at,
            because that is the only ordering in which a keyset walk over a mutable column
            cannot skip a row. An order that changes mid-walk moves forward past your
            position and is returned again, so the walk is AT LEAST ONCE: apply the state of
            each row you receive and a repeat costs nothing, while a miss would be a
            fulfilment or refund you never see. A cursor from one ordering is refused by the
            other, since the same position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_due_minor:
                          type: integer
                        billing_address:
                          nullable: true
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          nullable: true
                        discount_total_minor:
                          type: integer
                        display_id:
                          type: integer
                        email:
                          type: string
                        gift_card_total_minor:
                          type: integer
                        id:
                          type: string
                        line_items:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              product_title:
                                type: string
                              quantity:
                                type: integer
                              unit_price_minor:
                                type: integer
                              variant_id:
                                type: [string, "null"]
                              variant_title:
                                type: string
                        loyalty_total_minor:
                          type: integer
                        shipping_address:
                          nullable: true
                        shipping_option_name:
                          nullable: true
                        shipping_total_minor:
                          type: integer
                        status:
                          type: string
                        store_credit_total_minor:
                          type: integer
                        subtotal_minor:
                          type: integer
                        tax_total_minor:
                          type: integer
                        total_minor:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}:
    get:
      summary: Retrieve an order
      operationId: get_orders_by_orderId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_orders
      x-required-scopes: [read_orders]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_due_minor:
                        type: integer
                      billing_address:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        nullable: true
                      discount_total_minor:
                        type: integer
                      display_id:
                        type: integer
                      email:
                        type: string
                      gift_card_total_minor:
                        type: integer
                      id:
                        type: string
                      line_items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: [string, "null"]
                            variant_title:
                              type: string
                      loyalty_total_minor:
                        type: integer
                      shipping_address:
                        nullable: true
                      shipping_option_name:
                        nullable: true
                      shipping_total_minor:
                        type: integer
                      status:
                        type: string
                      store_credit_total_minor:
                        type: integer
                      subtotal_minor:
                        type: integer
                      tax_total_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/cancel:
    post:
      summary: Cancel an order
      operationId: post_orders_by_orderId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_orders
      x-required-scopes: [write_orders]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                TAKES NO PARAMETERS, and an empty body is the ordinary call. The empty
                key set is ENFORCED rather than ignored: reason, refund, restock and
                notify_customer are each refused BY NAME, so a client that assumes this
                route works like another platform's cancel is told the field does
                nothing instead of reading the 200 as agreement to it. There is no
                reason column on an order, money is the payment module's, the inventory
                release is unconditional so there is nothing to opt into, and the
                shopper email has no suppression path. The provenance and snapshot keys
                (id, display_id, created_at, updated_at, currency_code and every _minor
                total) are refused the same way, because the server owns them and an
                order's money is snapshotted at placement; status is refused because
                cancelling IS the one transition this api can verify, and metadata
                belongs to PUT /api/v1/orders/{orderId}/metadata.
                IT IS NOT REVERSIBLE. cancelled is terminal in the order state machine,
                so no route moves the order back and the record stands cancelled for
                good. It is a POST rather than a DELETE because the order row survives:
                nothing is removed.
                WHAT IT DOES TO MONEY: in the same transaction it returns the promotion
                campaign budget the order's discount drew and credits the gift card,
                store credit and loyalty tenders back to the shopper, every figure in
                integer minor units. IT DOES NOT REFUND A CARD CHARGE and calls nothing
                in the payment module, which is why an order that is already PAID is
                refused with 409 cancel_requires_refund instead of being cancelled with
                the charge still captured. Refund it from the dashboard first.
                WHAT IT DOES TO STOCK: it releases the order's inventory reservations
                back to available in that same transaction. The release is mandatory
                here rather than best effort, so a deployment that has not wired it is
                refused with 503 cancel_unavailable BEFORE the transaction opens.
                Nothing half-happens, and the order is never left cancelled with its
                stock stranded as reserved.
                It also emails the shopper a cancellation and enqueues the
                orders/cancelled webhook to the store's endpoints.
                A RETRY IS SAFE PAST THE IDEMPOTENCY WINDOW. Replaying the
                Idempotency-Key returns the recorded response, and a retry arriving
                after the lease has expired re-executes, finds the order already
                cancelled and answers 200 as a no-op: no second status write, no second
                webhook and no second shopper email. An order in a status that cannot
                reach cancelled (fulfilled, completed, refunded) is 409
                invalid_transition.
                The response is the whole order in the read's shape, byte-identical to
                GET /api/v1/orders/{orderId}, never a 204.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_due_minor:
                        type: integer
                      billing_address:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        nullable: true
                      discount_total_minor:
                        type: integer
                      display_id:
                        type: integer
                      email:
                        type: string
                      gift_card_total_minor:
                        type: integer
                      id:
                        type: string
                      line_items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: [string, "null"]
                            variant_title:
                              type: string
                      loyalty_total_minor:
                        type: integer
                      shipping_address:
                        nullable: true
                      shipping_option_name:
                        nullable: true
                      shipping_total_minor:
                        type: integer
                      status:
                        type: string
                      store_credit_total_minor:
                        type: integer
                      subtotal_minor:
                        type: integer
                      tax_total_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/confirmations/resend:
    post:
      summary: Re-send an order confirmation
      operationId: post_orders_by_orderId_confirmations_resend
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_notifications
      x-required-scopes: [write_notifications]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Sends the order's
                confirmation email again, for a customer who says theirs never arrived.
                THE RECIPIENT IS READ FROM THE ORDER and cannot be named. to, to_address,
                email, recipient, address, cc, bcc, from and customer_id are each REFUSED
                BY NAME rather than ignored: that parameter is the entire difference
                between a resend and an open relay pointed at this merchant's sending
                domain and reputation. The copy is refused too, for the reason
                POST /notifications/test records: subject, body, template and payload all
                come from the store's own template for the event.
                THIS IS THE MIRROR OF THE TEST SEND. That route lets you name an address
                and refuses the copy; this one lets you name neither, because the address
                already exists on the order and any other would be mail the merchant did
                not authorise.
                IT SENDS REGARDLESS OF ANY PRIOR ATTEMPT, which is what makes it a resend
                rather than a dispatch. The send log keeps one "sent" marker per order, so
                a second call mails the customer again and leaves that marker alone; the
                response still reports what happened.
                USE A FRESH Idempotency-Key ON EVERY DELIBERATE RESEND, and reuse the key
                when RETRYING one. A replayed key answers with the first call's outcome
                and sends nothing, which is exactly what stops an ordinary network retry
                from putting a duplicate in a shopper's inbox.
                200, not 201: nothing addressable is created here. The response carries
                the notification id so you can follow the delivery on
                GET /api/v1/notifications/{notificationId}.
                A FAILED DELIVERY IS STILL A 200, carrying status "failed" and a null
                notification_id. The request was carried out and the provider declined,
                which is an outcome rather than a fault of yours; a 5xx would release the
                idempotency reservation and your retry would send the email again. The
                provider's own error text is not published: it is written by a third party
                and can quote the recipient address back, which this response withholds.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      notification_id:
                        type: [string, "null"]
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_notifications scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/fulfillments:
    post:
      summary: Create a fulfillment against an order
      operationId: post_orders_by_orderId_fulfillments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_fulfillment
      x-required-scopes: [write_fulfillment]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              additionalProperties: false
              description: >-
                Creates the shipment as PENDING and notifies nobody. Handing it to
                a carrier, which is what mails the shopper, is
                POST /fulfillments/{fulfillmentId}/ship. The order must be paid or
                already fulfilled. To ship part of an order, create two
                fulfillments and ship each; a fulfillment ships all of its own
                lines in one statement.
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 500
                  description: >-
                    The order lines this shipment covers. Addressed by
                    order_line_item_id, which the v1 order read and the
                    fulfillments/created webhook both publish, never by variant id.
                  items:
                    type: object
                    required: [order_line_item_id, quantity]
                    additionalProperties: false
                    properties:
                      order_line_item_id:
                        type: string
                      quantity:
                        type: integer
                        minimum: 1
                        maximum: 1000000
                        description: >-
                          Required on every item. Omitting it cannot mean "the rest
                          of the line", because that would make one body mean
                          different things at different times. Exceeding the line's
                          unfulfilled amount is refused with 409.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      carrier:
                        nullable: true
                      created_at:
                        type: string
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      shipped_at:
                        nullable: true
                      status:
                        type: string
                      tracking_number:
                        nullable: true
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_fulfillment scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/gift-cards:
    get:
      summary: List an order's gift cards
      operationId: get_orders_by_orderId_gift_cards
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_gift_cards
      x-required-scopes: [read_gift_cards]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        balance_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        display_prefix:
                          type: string
                        expires_at:
                          nullable: true
                        id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_gift_cards scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/metadata:
    put:
      summary: Replace an order's metadata document
      operationId: put_orders_by_orderId_metadata
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_orders
      x-required-scopes: [write_orders]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [metadata]
              additionalProperties: false
              description: >-
                REPLACES the document. Every key you omit is removed, which is why
                this is a PUT on its own route rather than a field on a patch: the
                method is what says the omission was deliberate. Send {} to clear
                every caller-owned key. Omitting metadata entirely is refused
                rather than read as a clear.
              properties:
                metadata:
                  type: object
                  additionalProperties: true
                  maxProperties: 64
                  description: >-
                    At most 64 top-level keys serialising to at most 16384 bytes.
                    Unlike a product's attributes this document is SHARED with the
                    platform: optimize_vid is written by checkout and read back for
                    order attribution, so naming it is refused and its stored value
                    is re-attached across the replace.
                expected_updated_at:
                  type: string
                  format: date-time
                  description: >-
                    OPTIONAL precondition. Because this replaces the WHOLE document,
                    the read-modify-write a client is told to perform is a race: send
                    the updated_at you read and the write is refused with 409
                    order_modified if the order moved since. Omit it to accept
                    last-write-wins, which is how this route has always behaved. It
                    rides the body rather than If-Match because a replayed idempotent
                    request preserves the body and not the headers.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_due_minor:
                        type: integer
                      billing_address:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        nullable: true
                      discount_total_minor:
                        type: integer
                      display_id:
                        type: integer
                      email:
                        type: string
                      gift_card_total_minor:
                        type: integer
                      id:
                        type: string
                      line_items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: [string, "null"]
                            variant_title:
                              type: string
                      loyalty_total_minor:
                        type: integer
                      metadata:
                        type: object
                        properties:
                          erp_ref:
                            type: string
                      shipping_address:
                        nullable: true
                      shipping_option_name:
                        nullable: true
                      shipping_total_minor:
                        type: integer
                      status:
                        type: string
                      store_credit_total_minor:
                        type: integer
                      subtotal_minor:
                        type: integer
                      tax_total_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/offline-tender:
    get:
      summary: Retrieve order offline tender
      description: >-
        How ONE order was tendered offline: cash on delivery, cheque, bank transfer or
        trade credit, with the date payment is due. It is the fact neither the order nor
        the payment resource carries, and without it an order taken on 30-day trade credit
        and one taken on a bounced cheque read identically as an unsettled payment.
        A 404 MEANS THE ORDER WAS NOT TAKEN OFFLINE, which is an answer rather than a
        failure. An order paid through Stripe, Razorpay or Dodo has no offline-tender
        record at all, so this is how you tell a processor payment from an offline one.
        The same 404 covers an order id from another store and one that does not exist,
        so it is not a probe for which order ids a merchant has.
        due_at IS NULL FOR MOST TENDERS. Cash on delivery has no due date, and a merchant
        recording a cheque may not set one. Null means there is no agreed date, NOT that
        payment is overdue: fall back to created_at, which is when the tender was
        recorded, for an ageing calculation.
        NO created_by. The merchant user who recorded the tender is staff identity and
        this API publishes none: it would tell you which of a merchant's employees took a
        cheque at the counter, which is their internal staffing and not your order's
        payment terms.
        READ ONLY, and settlement is not on this API in any form. Marking an offline
        payment collected transitions the order to paid, commits its inventory
        reservations, releases held trade credit and sends the confirmation email. That is
        a human deciding a cheque cleared; an api key asserting it would ship goods that
        were never paid for, and the call looks identical either way.
      operationId: get_orders_by_orderId_offline_tender
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      due_at:
                        type: [string, "null"]
                      method:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/refunds:
    get:
      summary: List an order's refunds
      operationId: get_orders_by_orderId_refunds
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_payments
      x-required-scopes: [read_payments]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        order_id:
                          type: string
                        payment_id:
                          type: string
                        provider:
                          type: string
                        provider_refund_ref:
                          type: string
                        reason:
                          type: string
                        refund_reason_id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/returns:
    post:
      summary: Open a return against an order
      operationId: post_orders_by_orderId_returns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              additionalProperties: false
              description: >-
                The order is the path and cannot be repeated in the body, so the two
                can never disagree. The return opens as 'requested': a merchant still
                approves it, because a key that could open an already-approved return
                would be the approval gate defeated by the create route.
                The refund figure is COMPUTED from the returned lines and then scaled
                by the order's own discounts, gift cards and credit, so it is never
                sent: this grant chooses whether to refund and never how much.
                location_id is refused by name. Where returned stock lands is
                write_inventory's authority, so a return opened here carries no restock
                destination and receiving it records the receipt without moving stock,
                exactly as every shopper-raised return does today.
                A note is refused too: the column it would land in is shared with
                merchant prose about the shopper, and the read publishes it only for
                text the shopper wrote, so anything sent here could never be read back.
              properties:
                items:
                  type: array
                  minItems: 1
                  description: >-
                    The lines coming back, in the order they are stored and served in.
                    A line may appear twice; the cumulative quantity is capped at what
                    is left to return on it, counting earlier returns.
                  items:
                    type: object
                    required: [order_line_item_id, quantity]
                    additionalProperties: false
                    properties:
                      order_line_item_id:
                        type: string
                        description: A line of the order in the path. One from another order is a 400.
                      quantity:
                        type: integer
                        minimum: 1
                        description: >-
                          Required, with no default. Omitting it cannot mean "the rest of
                          the line", because that would make one body return different
                          amounts at different times.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        nullable: true
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/orders/{orderId}/shipping-address:
    put:
      summary: Replace an order's shipping address
      operationId: put_orders_by_orderId_shipping_address
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_orders
      x-required-scopes: [write_orders]
      parameters:
        - name: orderId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name, address_1, city, postal_code, country_code]
              additionalProperties: false
              description: >-
                The body IS the address document; there is no shipping_address
                wrapper. It REPLACES the stored address wholesale, so an omitted
                optional field is stored as empty: correcting a postal code without
                resending phone erases the phone number the carrier needs. Refused
                with 409 once the order carries a shipment or has moved past paid,
                because after that the parcel has gone and a rewrite would make the
                record lie about where it went. Money is snapshotted at placement,
                so this does NOT recompute tax for the new destination.
              properties:
                first_name:
                  type: string
                  maxLength: 255
                  description: "Required."
                last_name:
                  type: string
                  maxLength: 255
                  description: "Required."
                address_1:
                  type: string
                  maxLength: 255
                  description: "Required."
                address_2:
                  type: string
                  maxLength: 255
                city:
                  type: string
                  maxLength: 128
                  description: "Required."
                province:
                  type: string
                  maxLength: 128
                postal_code:
                  type: string
                  maxLength: 32
                  description: "Required."
                country_code:
                  type: string
                  maxLength: 2
                  description: "Required, exactly two letters. Stored LOWERCASED, so a client that sends \"US\" reads \"us\" back from the next GET and must not treat that as a change it did not make."
                phone:
                  type: string
                  maxLength: 32
                company:
                  type: string
                  maxLength: 255
                expected_updated_at:
                  type: string
                  format: date-time
                  description: >-
                    OPTIONAL precondition, the same contract as the metadata replace.
                    Send the updated_at you read and the write is refused with 409
                    order_modified if the order moved since. Omit it to accept
                    last-write-wins. It is checked BEFORE the shipped/paid gate, so a
                    caller working from a stale read is told the order moved rather
                    than that it has shipped.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_due_minor:
                        type: integer
                      billing_address:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        nullable: true
                      discount_total_minor:
                        type: integer
                      display_id:
                        type: integer
                      email:
                        type: string
                      gift_card_total_minor:
                        type: integer
                      id:
                        type: string
                      line_items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            product_title:
                              type: string
                            quantity:
                              type: integer
                            unit_price_minor:
                              type: integer
                            variant_id:
                              type: [string, "null"]
                            variant_title:
                              type: string
                      loyalty_total_minor:
                        type: integer
                      shipping_address:
                        type: object
                        properties:
                          address_1:
                            type: string
                          address_2:
                            type: string
                          city:
                            type: string
                          company:
                            type: string
                          country_code:
                            type: string
                          first_name:
                            type: string
                          last_name:
                            type: string
                          phone:
                            type: string
                          postal_code:
                            type: string
                          province:
                            type: string
                      shipping_option_name:
                        nullable: true
                      shipping_total_minor:
                        type: integer
                      status:
                        type: string
                      store_credit_total_minor:
                        type: integer
                      subtotal_minor:
                        type: integer
                      tax_total_minor:
                        type: integer
                      total_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_orders scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/pages:
    get:
      summary: List pages
      operationId: get_pages
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pages
      x-required-scopes: [read_pages]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, published] }
          description: >-
            Filter by publication state. Omit it to receive both. An unrecognised
            value is a 400 rather than an empty page: a client filtering on "live"
            would otherwise conclude the store has no published pages.
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: >-
            Case-insensitive substring match on slug and title. SQL wildcards in the
            term are escaped, so a literal % or _ matches itself.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Pages touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading every page. That saving is larger here than on other
            lists, because this list carries the page BODY: without it a client re-reads
            the store's entire content on every sync. Supplying it CHANGES THE ORDER of
            this endpoint: the default walk descends created_at, and this one climbs
            updated_at. The direction is the whole point. Descending updated_at is
            unusable, because an edited page teleports to the head of the list and a
            paging client sees it twice while missing whatever it displaced; ascending,
            that same edit moves the row forward past your position and is simply handed
            to you again. So the walk is AT LEAST ONCE: apply the state of each row you
            receive and a repeat costs nothing, while a miss means serving stale body
            content you have no way to discover is stale. The bound is INCLUSIVE, so
            resuming from the newest updated_at you hold re-delivers the rows sharing that
            instant rather than dropping the ones your last page did not reach. A cursor
            belongs to ONE ordering and is refused by the other. DRAFTS ARE INCLUDED here
            exactly as in the default list, because this read feeds the merchant's own
            migration and review tooling.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        body:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        meta_description:
                          type: [string, "null"]
                        meta_title:
                          type: [string, "null"]
                        published_at:
                          type: [string, "null"]
                        slug:
                          type: string
                        status:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a content page
      operationId: post_pages
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pages
      x-required-scopes: [write_pages]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, title]
              additionalProperties: false
              description: >-
                EVERY page created here lands as a DRAFT, and status is not settable:
                publishing puts content in front of every shopper immediately, with no
                review step, so it is a second deliberate act at POST
                /pages/{pageId}/publish. A body carrying "status" is refused rather
                than ignored, because a 201 that quietly dropped it would leave the
                client believing a page is live that no shopper can reach.
                The slug must be unique in the store; a taken slug is 409.
              properties:
                slug:
                  type: string
                  description: The storefront path segment. Unique per store.
                title:
                  type: string
                body:
                  type: string
                  description: Markdown, not HTML.
                meta_title:
                  type: [string, "null"]
                meta_description:
                  type: [string, "null"]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      meta_description:
                        type: [string, "null"]
                      meta_title:
                        type: [string, "null"]
                      published_at:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/pages/{pageId}:
    delete:
      summary: Delete a content page
      operationId: delete_pages_by_pageId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pages:delete
      x-required-scopes: [write_pages, write_pages:delete]
      parameters:
        - name: pageId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pages and then on write_pages:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pages, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a page
      operationId: get_pages_by_pageId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pages
      x-required-scopes: [read_pages]
      parameters:
        - name: pageId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      meta_description:
                        type: [string, "null"]
                      meta_title:
                        type: [string, "null"]
                      published_at:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a content page
      operationId: patch_pages_by_pageId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pages
      x-required-scopes: [write_pages]
      parameters:
        - name: pageId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                An omitted field is left unchanged; meta_title and meta_description
                sent as NULL are CLEARED, which is the only way to remove a stale SEO
                title that is still being served. slug, title and body are NOT NULL
                columns, so null on any of them is a 400 rather than a silent skip.
                A patch naming nothing is refused, so a client with a misspelled field
                does not read a green light for a no-op.
                PATCH rather than PUT: status, published_at, translations and
                metafields are all state this body cannot express, so a
                whole-resource replacement reading would be a lie. Every value is an
                absolute target, so replaying converges.
                CHANGING slug CHANGES THE LIVE URL and nothing writes a redirect for
                you: every inbound link, sent email and indexed result pointing at the
                old path 404s from the moment this returns 200. Renaming an unpublished
                page is free because a draft is not served. After publish, pair the
                rename with POST /api/v1/redirects in the same run.
              properties:
                slug:
                  type: string
                title:
                  type: string
                body:
                  type: string
                meta_title:
                  type: [string, "null"]
                meta_description:
                  type: [string, "null"]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      meta_description:
                        type: [string, "null"]
                      meta_title:
                        type: [string, "null"]
                      published_at:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/pages/{pageId}/publish:
    post:
      summary: Publish a content page
      operationId: post_pages_by_pageId_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pages
      x-required-scopes: [write_pages]
      parameters:
        - name: pageId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Puts the page in
                front of every shopper immediately: there is no review step, no
                scheduling and no event telling anyone. published_at is stamped on
                FIRST publish and then frozen, so a republish does not restate the
                page as new.
                Reversed by POST /pages/{pageId}/unpublish. Publishing an
                already-published page is not an error.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      meta_description:
                        type: [string, "null"]
                      meta_title:
                        type: [string, "null"]
                      published_at:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/pages/{pageId}/unpublish:
    post:
      summary: Unpublish a content page
      operationId: post_pages_by_pageId_unpublish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pages
      x-required-scopes: [write_pages]
      parameters:
        - name: pageId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Returns the page
                to draft, removing it from the storefront while keeping every field
                and its original published_at.
                This is usually what a migration wants rather than a delete: unpublish
                is reversible and DELETE is not. Reach for DELETE only when the page
                and its slug should stop existing.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      meta_description:
                        type: [string, "null"]
                      meta_title:
                        type: [string, "null"]
                      published_at:
                        type: [string, "null"]
                      slug:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pages scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/payments:
    get:
      summary: List payments
      operationId: get_payments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_payments
      x-required-scopes: [read_payments]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this order_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        order_id:
                          type: string
                        provider:
                          type: string
                        provider_ref:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/payments/{paymentId}:
    get:
      summary: Retrieve a payment
      operationId: get_payments_by_paymentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_payments
      x-required-scopes: [read_payments]
      parameters:
        - name: paymentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      order_id:
                        type: string
                      provider:
                        type: string
                      provider_ref:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists:
    get:
      summary: List price lists
      operationId: get_price_lists
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, disabled] }
          description: >-
            The vocabulary is CLOSED and an unrecognised value is a 400, not an empty page: a
            client sending ?status=enabled would otherwise read a typo as "this store has no
            price lists in that state" and stop asking.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Price lists touched at or after this RFC3339 instant. A PRICE LIST DECIDES WHAT A
            SHOPPER IS CHARGED: its status, its window and the prices hanging off it, so a
            client caching lists keeps applying one the merchant has since disabled or
            re-dated. created_at is frozen at creation and says nothing about any of that.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A list edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other. NOTE that this walk tracks the LIST, not its prices: editing
            a price under a list does not move the list, so reconcile prices from the list's
            own prices sub-resource.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        customer_group_ids:
                          type: array
                          items:
                            type: string
                        description:
                          nullable: true
                        ends_at:
                          nullable: true
                        id:
                          type: string
                        starts_at:
                          nullable: true
                        status:
                          type: string
                        title:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a price list
      operationId: post_price_lists
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, type]
              additionalProperties: false
              description: >-
                The list is inert until prices land on it through
                POST /price-lists/{priceListId}/prices, and it defaults to disabled
                so that going live is a deliberate second act. Nothing is emitted:
                this family publishes no webhook topics, so clients poll.
              properties:
                title:
                  type: string
                  description: "Required on create."
                type:
                  type: string
                  enum: [sale, override]
                  description: "Required on create. A list price applies only when it is strictly BELOW the variant's base price, on both types: a price INCREASE pushed through an \"override\" list answers 200 and changes nothing at checkout."
                status:
                  type: string
                  enum: [active, disabled]
                  description: "Defaults to disabled on create, so a public create cannot put pricing in front of shoppers without a second deliberate act. Flipping to active takes effect at the next cart recompute: resolution happens at read time and is never snapshotted, so carts already in flight re-price."
                starts_at:
                  type: string
                  format: date-time
                ends_at:
                  type: string
                  format: date-time
                description:
                  type: string
                  maxLength: 2000
                  description: "Merchant notes. Never shown to a shopper."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_group_ids:
                        type: array
                        items:
                          {}
                      description:
                        nullable: true
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      title:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists/{priceListId}:
    get:
      summary: Retrieve a price list
      operationId: get_price_lists_by_priceListId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_group_ids:
                        type: array
                        items:
                          {}
                      description:
                        nullable: true
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      title:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a price list
      operationId: patch_price_lists_by_priceListId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Every value is an absolute target, so replaying converges. Setting
                status to disabled removes the list from resolution completely and
                immediately, and destroys nothing: it is the reversible operation
                published in place of a list delete, which would cascade this
                list's prices and its customer-group targeting with no restore.
              properties:
                title:
                  type: string
                  description: "Required on create."
                type:
                  type: string
                  enum: [sale, override]
                  description: "Required on create. A list price applies only when it is strictly BELOW the variant's base price, on both types: a price INCREASE pushed through an \"override\" list answers 200 and changes nothing at checkout."
                status:
                  type: string
                  enum: [active, disabled]
                  description: "Defaults to disabled on create, so a public create cannot put pricing in front of shoppers without a second deliberate act. Flipping to active takes effect at the next cart recompute: resolution happens at read time and is never snapshotted, so carts already in flight re-price."
                starts_at:
                  type: [string, "null"]
                  format: date-time
                ends_at:
                  type: [string, "null"]
                  format: date-time
                description:
                  type: [string, "null"]
                  maxLength: 2000
                  description: "Merchant notes. Never shown to a shopper."
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_group_ids:
                        type: array
                        items:
                          {}
                      description:
                        nullable: true
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      title:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists/{priceListId}/customer-groups:
    get:
      summary: List a price list's customer groups
      description: >-
        Which customer groups a price list applies to, paged. The same ids the price list
        carries inline on customer_group_ids, in the form that field cannot be: that array
        is unbounded on a resource whose page cap says nothing about how many groups it
        names, so a list targeting 5,000 tiers puts 5,000 ids into one row of a 100-row
        page. Read the array for the ordinary small case and this route when the set is
        large or you want to walk it.
        AN EMPTY PAGE MEANS EVERY CUSTOMER, not none. A price list with no targeting rows
        applies store-wide, so [] is the merchant's most consequential setting rather than
        an unconfigured state. Do not treat it as a draft.
        A PRICE LIST THAT DOES NOT EXIST IS A 404, never an empty page, and given what an
        empty page means that distinction is not cosmetic: reading one as the other would
        say the store prices its whole catalogue at a wholesale tier.
        IDS ONLY, and the group's name is deliberately not here. Names and metadata belong
        to the customer-groups family under read_customers, so a key holding only
        read_pricing cannot read the store's segment names as a side effect of a price
        sync. Resolve an id with GET /api/v1/customer-groups/{customerGroupId}.
        WITH GET /api/v1/customer-groups/{customerGroupId}/customers this is the whole B2B
        sync path: price list to groups to customers.
        created_at is when the GROUP WAS TARGETED. It is neither the price list's nor the
        group's own timestamp, and it is the cursor column.
        TARGETING IS READ-ONLY HERE. Nothing on this API adds or removes a target: a price
        list left with no targeting rows applies to every customer, so an untarget call
        would turn a wholesale tier into the public price and answer 204.
      operationId: get_price_lists_by_priceListId_customer_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        customer_group_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Target a price list at one customer group
      operationId: post_price_lists_by_priceListId_customer_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_group_id]
              additionalProperties: false
              description: >-
                WHO the list's prices apply to, which decides what those shoppers are CHARGED
                without changing a single amount.
                ONE GROUP PER CALL, never an array, and customer_group_ids is refused BY NAME
                for that reason: an array on this resource could only be a REPLACEMENT SET,
                and its destructive value is the EMPTY one, because a list with no targeting
                rows applies to EVERY customer. A sync client serialising an unset field as an
                empty array would turn a wholesale tier's prices public and read the 200 as
                success. A single id per call cannot express that by accident.
                ADDING THE FIRST GROUP NARROWS THE LIST. An untargeted list is store-wide, so
                this call takes the list's prices away from every shopper outside the group it
                names. The DELETE beside it is the mirror of that trap rather than its undo:
                removing the LAST targeting row does not untarget a list, it applies the list
                to every customer, so the removal that looks like a narrowing is the widest
                write in this family. That is why it carries write_pricing:delete on top of
                write_pricing while this route takes write_pricing alone.
                200 rather than 201, because the insert is ON CONFLICT DO NOTHING: a repeat
                creates nothing and returns the original row with its original created_at, so
                replaying converges by construction. There is no Location to point at either,
                since this resource has no id of its own and its primary key is
                (price_list_id, customer_group_id), both halves of which the caller just sent.
                The parent list is verified FIRST, inside the same transaction, so a
                nonexistent list is a 404 rather than a foreign-key violation answered 500. A
                group belonging to another store gets the same 404 as one that never existed,
                so this route is not an existence oracle over another merchant's segment ids.
                Nothing is emitted: this family registers no webhook topics, so a client that
                needs to know a list was retargeted must poll.
                id, price_list_id and created_at are refused BY NAME rather than ignored: this
                resource has no id of its own, the list is in the path, and the server owns the
                timestamp. group_id is refused too, because the field is customer_group_id.
                Any other key is a 400 naming the field, so a misspelling is never a silent
                no-op answered 200.
              properties:
                customer_group_id:
                  type: string
                  description: >-
                    Required, and refused rather than defaulted: an absent id would reach the
                    service as the empty string, and the caller would be told a group they
                    never sent was not found. It may not be null, because the column is NOT
                    NULL and this route has no clear. It names a customer group in THIS store,
                    opaque here and resolved with
                    GET /api/v1/customer-groups/{customerGroupId}, which is read_customers
                    rather than read_pricing, so a key that syncs prices cannot read the
                    merchant's segment names as a side effect.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_group_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists/{priceListId}/customer-groups/{customerGroupId}:
    delete:
      summary: Stop a price list applying to one customer group
      operationId: delete_price_lists_by_priceListId_customer_groups_by_customerGroupId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing:delete
      x-required-scopes: [write_pricing, write_pricing:delete]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: customerGroupId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The pair being removed is
                entirely in the path, because price_list_customer_group has no id of its own
                and its primary key is (price_list_id, customer_group_id). The body is still
                READ AND VALIDATED so a client that assumes otherwise is told: customer_group_id
                is refused BY NAME because it is in the path, and customer_group_ids because an
                array here would read as a REPLACEMENT SET whose empty value means "applies to
                every customer", so a request meaning "these are the only groups left" would
                remove one group and report success. group_id, price_list_id, id and created_at
                are refused for the usual reasons.
                REMOVING THE LAST TARGET PUBLISHES THE LIST TO EVERY SHOPPER. This is the trap,
                and it is the opposite of what the word untarget suggests: a price list with NO
                targeting rows applies store-wide, so taking away the final group does not
                retire a wholesale tier, it hands its negotiated prices to the whole store.
                Nothing fails, nothing is emitted, and the first evidence is an order at a
                trade price from a shopper who never negotiated one. The reversible way to stop
                a list applying is PATCH /api/v1/price-lists/{priceListId} with status
                disabled, which removes it from resolution entirely and destroys nothing.
                NO MONEY MOVES HERE, but what shoppers are CHARGED changes at the next
                resolution: the applicable list price is worked out when a cart is priced
                rather than stamped onto it, so carts already open re-price at their next
                read. This family registers no webhook topics, so a client mirroring
                targeting must poll.
                IT TAKES write_pricing:delete ON TOP OF write_pricing, unlike the POST beside
                it, for exactly that reason: a nightly sync that only adds targets needs the
                parent grant alone.
                404 WHEN THE PAIR IS NOT THERE, which is where this route diverges from the
                price DELETE. That one answers 204 either way so a lost response cannot make a
                success look like a failure; here the ledger already covers that, because
                replaying the Idempotency-Key returns the stored 204 without re-executing, and
                only a FRESH key reaches the statement a second time. What 204-always would
                cost is real: targeting is subtractive and unreadable from the answer alone, so
                a mistyped customer_group_id answered 204 would leave that tier still receiving
                these prices while the caller recorded a successful removal. A nonexistent
                price list is a 404 as well. THE GROUP ITSELF IS NOT CHECKED, deliberately: a
                targeting row whose customer group has since been deleted would otherwise be
                unremovable through this API forever.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pricing and then on write_pricing:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pricing, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists/{priceListId}/prices:
    get:
      summary: List a price list's prices
      operationId: get_price_lists_by_priceListId_prices
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Upsert prices onto a price list
      operationId: post_price_lists_by_priceListId_prices
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prices]
              additionalProperties: false
              description: >-
                A MERGE keyed on (variant_id, currency_code): a variant you omit
                keeps its existing price. That is why this is a POST and not a PUT,
                even though a GET on this path serves the collection. Replaying the
                same body converges. The whole batch runs in one transaction, so
                the first refusal writes nothing and there is no partial success to
                report. The response is the written prices in request order.
              properties:
                prices:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    type: object
                    required: [variant_id, currency_code, amount_minor]
                    additionalProperties: false
                    properties:
                      variant_id:
                        type: string
                      currency_code:
                        type: string
                        description: >-
                          In the same object as the amount, never in the path:
                          money and its unit belong together.
                      amount_minor:
                        type: integer
                        minimum: 0
                        maximum: 1000000000000
                        description: >-
                          REQUIRED, and omitting it is refused rather than defaulted
                          to zero. A list price applies whenever it is strictly
                          below the base price, so a zero here makes the line free
                          and the order takes the free checkout path. The merchant
                          API spells this field "amount"; that spelling is refused
                          here by name.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-lists/{priceListId}/prices/{variantId}/{currencyCode}:
    delete:
      summary: Remove one price from a price list
      operationId: delete_price_lists_by_priceListId_prices_by_variantId_by_currencyCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing:delete
      x-required-scopes: [write_pricing, write_pricing:delete]
      parameters:
        - name: priceListId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: currencyCode
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The price is
                addressed by its natural key in the path. 204 whether or not a row
                was removed: reporting the miss as 404 would fail a retry after a
                lost 204 for a request that had SUCCEEDED, and "this price is not
                on this list" is the state the caller asked for either way.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pricing and then on write_pricing:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pricing, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-preferences:
    get:
      summary: List price preferences
      operationId: get_price_preferences
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Preferences touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading them all on every run. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, and this one climbs updated_at, because
            that is the only ordering in which a keyset walk over a mutable column cannot skip a
            row. A preference changed mid-walk moves forward past your position and is returned
            again, so the walk is AT LEAST ONCE: apply the state of each row you receive and a
            repeat costs nothing. WHAT A MISS COSTS HERE IS A WRONG PRICE: is_tax_inclusive decides
            whether a rendered price already contains tax, so a client that missed the flip does
            not show stale metadata, it shows the wrong number to a shopper. A cursor from one
            ordering is refused by the other, since the same position means something different in
            each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attribute:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        is_tax_inclusive:
                          type: boolean
                        updated_at:
                          type: string
                        value:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Set the tax-inclusive display convention for a currency or region
      operationId: post_price_preferences
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attribute, value, is_tax_inclusive]
              additionalProperties: false
              description: >-
                WHAT A SHOPPER IS SHOWN, not what they are charged. This row moves no money
                and changes no amount: it decides whether a price is RENDERED with tax already
                in it, for one currency or one region. Getting it wrong is a store showing
                every VAT-market shopper a price below the total checkout collects.
                AN UPSERT KEYED ON (attribute, value), which is why it is a POST to the
                collection and answers 200 rather than 201. The table carries
                UNIQUE (store_id, attribute, value) and the id is server-minted, so before a
                preference exists there is no path that could name it, and after it exists the
                natural key is still the only handle a client holds. is_tax_inclusive is an
                ABSOLUTE value, so a replay sets the same flag and the row keeps its id and
                its original created_by: a retry outside the idempotency window behaves
                exactly like one inside it, which is what makes this route safe for a
                migration tool to re-run.
                A REGION PREFERENCE BEATS A CURRENCY ONE, and this is the trap an integrator
                hits. Setting attribute currency_code with value usd to true does nothing for
                a shopper whose region already carries a preference of its own, and the 200
                says nothing about it. Read GET /api/v1/price-preferences and look at BOTH
                dimensions before concluding a write took effect. Neither dimension matching
                means tax-exclusive.
                IT TAKES EFFECT IMMEDIATELY. The storefront resolves this at READ time and
                never snapshots it, so every price rendered after this call uses the new
                convention and carts already open re-render. Nothing is emitted: this family
                registers no webhook topics, so a client caching the preference must poll.
                SETTING is_tax_inclusive FALSE IS NOT THE SAME AS DELETING THE ROW, and the
                difference decides which one you want. Resolution is the region row, then the
                currency row, then false, so false written HERE is PINNED and shadows the
                currency row underneath it, while DELETE
                /api/v1/price-preferences/{pricePreferenceId} removes the row and lets that
                currency row start winning. There is no value of is_tax_inclusive meaning
                "defer to the dimension below me". This route is the reversible one: it records
                the merchant's choice rather than erasing it, and it needs write_pricing alone
                where the delete also needs write_pricing:delete.
                id, created_at, updated_at and created_by are refused BY NAME rather than
                ignored: the server mints the id and owns the timestamps, and created_by is
                the merchant staff audit trail for SOC2, which an api key is not a member of.
                tax_inclusive and taxInclusive are refused by name because they are the two
                spellings a client reaches for first and a silent miss would leave the flag at
                false. currency_code and region_id are refused as KEYS: the code or the id
                goes in value, with attribute naming which dimension it is, so one route
                writes both.
              properties:
                attribute:
                  type: string
                  enum: [currency_code, region_id]
                  description: >-
                    Required. The dimension the preference is keyed on. The column carries a
                    CHECK constraint admitting exactly these two, so anything else is a 400
                    and a client may switch on it exhaustively today; a third value could only
                    ever arrive additively.
                value:
                  type: string
                  maxLength: 128
                  description: >-
                    Required, trimmed, and matched EXACTLY at resolution time. With attribute
                    currency_code it must be three ASCII letters and is stored lowercase, so
                    "USD" comes back as "usd"; with attribute region_id it is an opaque region
                    id whose case is preserved, because folding it would break a lookup rather
                    than fix one. The cap is 128 BYTES rather than characters, so a multi-byte
                    value reaches it sooner than its length suggests. An empty value is a 400.
                    A value that matches nothing is NOT an error, it is silently inert: the
                    store keeps rendering tax-exclusive prices while the merchant's dashboard
                    shows a preference that looks set.
                is_tax_inclusive:
                  type: boolean
                  description: >-
                    Required, and the whole payload of this resource. True means prices for
                    this currency or region are DISPLAYED with the tax already in them. It is
                    not defaulted, because omitting it would decode to false and show every
                    shopper in that currency or region a price below the one checkout
                    collects, with a 200 and no event to tell anyone. The admin body defaults
                    it; a published contract cannot.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attribute:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_tax_inclusive:
                        type: boolean
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/price-preferences/{pricePreferenceId}:
    delete:
      summary: Remove a tax-inclusive display preference
      operationId: delete_price_preferences_by_pricePreferenceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing:delete
      x-required-scopes: [write_pricing, write_pricing:delete]
      parameters:
        - name: pricePreferenceId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The row is named by the
                server-minted id in the path, not by the (attribute, value) pair the upsert is
                keyed on, so a client holding a stale pair cannot remove a preference the
                merchant has since retargeted. The body is still READ AND VALIDATED:
                is_tax_inclusive is refused BY NAME because a delete carries no flag AND
                because the two are different operations, attribute and value because this
                route addresses the row by id, and id, created_at and updated_at for the usual
                reasons.
                DELETING IS NOT WRITING false, and getting that backwards gives the opposite
                of what was intended. Resolution is the region row, then the currency row, then
                the literal false. Delete a REGION row and the CURRENCY row underneath it
                starts winning, so a store with currency usd true and region reg_x false flips
                reg_x to tax-inclusive display. Write false to that same region row instead and
                false is PINNED over the currency row whatever it holds. For a currency row
                with no region row matching the same shopper both paths land on false and the
                rendered price is identical; the delete still erases the merchant's recorded
                choice.
                REMOVING THE LAST ROW MATCHING A SHOPPER DROPS THEM TO TAX-EXCLUSIVE DISPLAY,
                which is a price shown BELOW what checkout collects. No money moves and no
                amount changes: this row decides what is SHOWN, and a VAT market rendering
                every price without its tax is the failure this family exists to prevent. It
                takes effect IMMEDIATELY, because the storefront resolves this at read time and
                never snapshots it, so carts already open re-render.
                NOT REVERSIBLE BY REPLAY. There is no soft delete and no restore, and
                re-creating the preference through the POST mints a NEW id and a new
                created_at, so an integration keyed on the old id has to be updated. Nothing is
                emitted, because this family registers no webhook topics. That is why this
                route takes write_pricing:delete on top of write_pricing while the upsert takes
                write_pricing alone.
                404 WHEN THE ID NAMES NOTHING, rather than a blanket 204. The id was minted by
                this platform rather than composed by the caller, so a miss can only mean the
                id is wrong or another actor already removed the row, and a client syncing
                display settings needs to hear both. Answering honestly costs a genuine retry
                nothing: replaying the Idempotency-Key returns the stored 204 without
                re-executing, and only a FRESH key against an already-deleted row reaches the
                404.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pricing and then on write_pricing:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pricing, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-questions:
    get:
      summary: List product questions
      description: >-
        Every question shoppers have asked on this store's products, in EVERY state,
        together with the merchant's answers to each. A question is pre-moderated: it sits
        pending until someone publishes or rejects it, so most of what this route serves is
        words the merchant has not published, attached to the customer id of the account
        that asked.
        THIS IS WHY IT IS NOT read_products. That grant is the first call a catalogue
        integration makes and is already minted on live keys, and scopes are immutable
        after mint, so folding questions into it would have handed every existing key this
        disclosure retroactively. Treat a pending body as unpublished content: do not
        syndicate it and do not show it to other customers.
        THERE IS NO DEFAULT FILTER. Omitting ?status= returns every status, which is what
        makes this usable for mirroring moderation state; pass ?status=pending for the
        queue proper. Only pending, published and rejected are accepted. Note that the
        publish state is spelled "published" while the route that sets it is called
        /approve, so ?status=approved is a 400 rather than an empty page: an empty page
        would read as an empty queue.
        IT BUYS DISCLOSURE, NOT AUTHORITY. Publishing, rejecting and answering is
        write_product_questions, a different grant that this one does not imply and that
        does not imply this one; a tool that both shows the queue and clears it asks for
        both.
        IT PUBLISHES THE QUESTION, NOT THE QUESTIONER. customer_id is the shopper who
        asked and is all this grant carries about them; the name, email and address behind
        it are read_customers, so resolve them there if your key holds it.
        NO PRODUCT TITLE. product_id is flat, because filling a nested reference would
        mean a join that silently drops a question whose product row was deleted, leaving
        it queued forever and invisible to every client. Resolve the product through the
        products family.
        answers is NEVER null. An unanswered question serialises an empty array, because
        absent would say "unknown" where the truth is "none yet".
      operationId: get_product_questions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_product_questions
      x-required-scopes: [read_product_questions]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, published, rejected] }
          description: >-
            Narrows to one moderation state. OMIT IT FOR EVERY STATE: there is no default
            filter, because a default would be a hidden WHERE clause and a client paging
            what it believed were the store's questions could not tell "only the pending
            ones" from "a store with nothing held". Pass pending for the moderation queue
            proper. A value outside this set is a 400 rather than an empty page, and
            "approved" is the one to watch: the route that sets the state is called
            /approve but the state itself is "published", so an empty page there would
            read as an empty queue.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        answers:
                          type: array
                          items:
                            type: object
                            properties:
                              body:
                                type: string
                              created_at:
                                type: string
                              id:
                                type: string
                        body:
                          type: string
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        id:
                          type: string
                        product_id:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_product_questions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-questions/{questionId}:
    get:
      summary: Retrieve a product question
      description: >-
        One question in any state, with its answers. This is how you read a question's
        status back: call it after approve or reject to confirm which state the write
        actually landed in, which matters most before a reject, since the same call
        declines a pending question and takes down a published one.
        THE SAME SHAPE THE LIST SERVES, and the same shape approve and reject answer with,
        so a row taken off a page can be re-read on its own rather than by walking the
        collection again.
        A 404 IS DELIBERATELY GENERIC and covers three conditions at once: no such id, an
        id belonging to another store, and a malformed id. Separating them would turn this
        route into a probe for which question ids exist in a store you cannot read.
      operationId: get_product_questions_by_questionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_product_questions
      x-required-scopes: [read_product_questions]
      parameters:
        - name: questionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answers:
                        type: array
                        items:
                          type: object
                          properties:
                            body:
                              type: string
                            created_at:
                              type: string
                            id:
                              type: string
                      body:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      product_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_product_questions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-questions/{questionId}/answers:
    post:
      summary: Answer a product question
      operationId: post_product_questions_by_questionId_answers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_product_questions
      x-required-scopes: [write_product_questions]
      parameters:
        - name: questionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              additionalProperties: false
              description: >-
                The merchant's own reply, published under the STORE's name and shown
                beside the product to every shopper. It is trusted without moderation
                because the merchant is the author, which is why authoring one rides this
                scope rather than needing a third.
                THERE IS NO AUTHOR FIELD, in any spelling. author, author_name,
                answered_by, created_by, user_id and staff_id are each refused BY NAME
                with a reason: the server stamps provenance, and product_answer has no
                author column, so a value would be accepted and silently discarded even
                if it were allowed. customer_id is refused too, because naming one would
                fabricate a shopper's words under a real account.
                NOT IDEMPOTENT BY CONSTRUCTION. A second call appends a SECOND answer.
                What protects a retry is the mandatory Idempotency-Key: reuse the key and
                the stored response replays instead of inserting again. Reconcile by
                reading the question, not by re-posting.
                The response is the ANSWER, not the question: it is the resource this call
                created and the only part of the reply you did not already send.
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: >-
                    Required. Trimmed, and must be non-empty and at most 2000 characters
                    after trimming; a blank or oversized body is a 400 rather than a
                    silently truncated answer appearing under the store's name.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_product_questions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-questions/{questionId}/approve:
    post:
      summary: Publish a product question
      operationId: post_product_questions_by_questionId_approve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_product_questions
      x-required-scopes: [write_product_questions]
      parameters:
        - name: questionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Publishes an
                existing shopper-authored question so it appears beside the product,
                with its answers, for every shopper. The status it writes is
                "published"; the route is named for the act and the resource is named
                for the state, so do not send ?status=approved to the list expecting to
                find it. Idempotent by construction: the status is an absolute
                assignment from any current state, so a replay outside the idempotency
                window converges on the same row. Reversed by
                POST /product-questions/{questionId}/reject. The response is the SAME
                shape the read serves, so you can treat it as a fresh read rather than
                following it with a GET.
                THE BODY REFUSES status, body, answers and every provenance key
                (id, store_id, customer_id, created_by, created_at, updated_at) BY NAME
                with a reason. status is refused because a field cannot say whether you
                meant to decline a pending question or take down a live one, which is
                exactly why there are two routes.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answers:
                        type: array
                        items:
                          {}
                      body:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      product_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_product_questions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-questions/{questionId}/reject:
    post:
      summary: Reject a product question
      operationId: post_product_questions_by_questionId_reject
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_product_questions
      x-required-scopes: [write_product_questions]
      parameters:
        - name: questionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. OVERLOADED, and the
                response cannot tell you which happened: the same call declines a pending
                question and TAKES DOWN a published one that shoppers can currently see,
                answering 200 either way. Unlike the review family you CAN pre-check,
                because GET /product-questions/{questionId} publishes the status; read it
                first if the difference matters to you. The shopper's words are untouched,
                so nothing is lost, and POST /product-questions/{questionId}/approve
                reverses it. Neither route can return a question to "never decided".
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      answers:
                        type: array
                        items:
                          {}
                      body:
                        type: string
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      product_id:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_product_questions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-types:
    get:
      summary: List product types
      operationId: get_product_types
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Product types touched at or after this RFC3339 instant. A type is the label you
            resolve a product's type_id against, so missing a rename leaves you captioning
            products with a name the merchant retired.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE:
            apply the state of each row you receive and a repeat costs nothing. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant rather than dropping the ones your last page did not reach.
            A cursor belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        metadata:
                          type: object
                          properties:
                            icon:
                              type: string
                        updated_at:
                          type: string
                        value:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/product-types/{productTypeId}:
    get:
      summary: Retrieve a product type
      operationId: get_product_types_by_productTypeId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productTypeId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      metadata:
                        type: object
                        properties:
                          icon:
                            type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products:
    get:
      summary: List products
      description: >-
        The store's products, newest first.
        ?updated_after= SELECTS A DIFFERENT ORDERING rather than filtering this one. The
        default walk descends created_at; with updated_after the list climbs updated_at
        ASCENDING, which is what makes an incremental sync safe over a mutable column: a
        product edited while you are paging moves FORWARD past your cursor and is handed
        to you again, instead of moving behind it and being skipped silently.
        A CURSOR BELONGS TO ONE WALK. Both carry a timestamp but compare it against
        different columns, so a cursor from the default list sent with updated_after is
        refused, and so is the reverse. Continue a sync walk by sending updated_after on
        every page along with the cursor.
        EXPECT TO SEE A ROW TWICE rather than to miss one: that is the trade this
        ordering makes, and it is the right way round for reconciliation.
        ?handle= resolves a storefront URL segment to a product. It is unique per store,
        so it returns at most one row.
      operationId: get_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: handle
          in: query
          required: false
          schema: { type: string }
          description: >-
            One product by its storefront URL segment, which is unique per store, so a page can
            be resolved in one call rather than by walking the catalogue. An EMPTY value is a 400
            rather than "no filter": it would otherwise return the whole catalogue to a caller
            that believes it holds one product.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Products in one state. An unrecognised value is a 400 naming the accepted set, not an
            empty page, because an empty page is indistinguishable from a store with none.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Products touched at or after this RFC3339 instant, for reconciling a catalogue
            incrementally instead of re-reading it. It does NOT filter this list: supplying it
            SELECTS A DIFFERENT ORDERING. The default walk descends created_at; this one climbs
            updated_at, because that is the only ordering in which a keyset walk over a mutable
            column cannot skip a row. A product edited mid-walk moves forward past your position
            and is returned again, so the walk is AT LEAST ONCE: apply the state of each row you
            receive and a repeat costs nothing, while a miss is a price or a title your catalogue
            never learns about. A cursor from one ordering is refused by the other, since the same
            position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attributes:
                          type: object
                        created_at:
                          type: string
                        description:
                          nullable: true
                        discountable:
                          type: boolean
                        handle:
                          type: string
                        height:
                          nullable: true
                        hs_code:
                          nullable: true
                        id:
                          type: string
                        images:
                          type: array
                          items:
                            type: object
                            properties:
                              alt_text:
                                nullable: true
                              id:
                                type: string
                              rank:
                                type: integer
                              url:
                                type: string
                        length:
                          nullable: true
                        material:
                          nullable: true
                        meta_description:
                          nullable: true
                        meta_title:
                          nullable: true
                        mid_code:
                          nullable: true
                        options:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: string
                              title:
                                type: string
                              values:
                                type: array
                                items:
                                  type: string
                        origin_country:
                          nullable: true
                        published_at:
                          nullable: true
                        status:
                          type: string
                        subtitle:
                          nullable: true
                        thumbnail:
                          nullable: true
                        title:
                          type: string
                        type_id:
                          type: [string, "null"]
                        unpublish_at:
                          nullable: true
                        updated_at:
                          type: string
                        variants:
                          type: array
                          items:
                            type: object
                            properties:
                              allow_backorder:
                                type: boolean
                              currency_code:
                                type: string
                              id:
                                type: string
                              manage_inventory:
                                type: boolean
                              price_minor:
                                type: integer
                              requires_shipping:
                                type: boolean
                              selections:
                                type: object
                                properties:
                                  Color:
                                    type: string
                                  Size:
                                    type: string
                              sku:
                                nullable: true
                              title:
                                type: string
                              variant_rank:
                                type: integer
                        weight:
                          nullable: true
                        width:
                          nullable: true
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a draft product
      operationId: post_products
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle, title]
              additionalProperties: false
              description: >-
                Creates the product as a DRAFT. Status is not settable here: a
                product goes live through POST /products/{productId}/publish,
                which is the only shape that carries the free-variant
                acknowledgement and a scheduled publish_at.
              properties:
                title:
                  type: string
                handle:
                  type: string
                  description: "Required on create, unlike the dashboard. Without it a retry outside the idempotency window mints a SECOND product at a suffixed handle; with it the retry collides and returns 409 handle_taken."
                subtitle:
                  type: string
                description:
                  type: string
                thumbnail:
                  type: string
                meta_title:
                  type: string
                meta_description:
                  type: string
                attributes:
                  type: object
                  additionalProperties: true
                  description: "Merchant-defined JSON, stored unchanged and unvalidated."
                discountable:
                  type: boolean
                  description: "Defaults to true when omitted."
                unpublish_at:
                  type: string
                  format: date-time
                material:
                  type: string
                weight:
                  type: string
                  description: "A decimal string, not a number, matching the column and the read shape."
                length:
                  type: string
                height:
                  type: string
                width:
                  type: string
                hs_code:
                  type: string
                mid_code:
                  type: string
                origin_country:
                  type: string
                options:
                  type: array
                  description: >-
                    Variant axes, fixed at creation. There is no route to change an
                    option afterwards: every variant's selection is a foreign key
                    into these values, so editing an axis would orphan them.
                  items:
                    type: object
                    required: [title, values]
                    additionalProperties: false
                    properties:
                      title:
                        type: string
                      values:
                        type: array
                        items: { type: string }
                variants:
                  type: array
                  description: >-
                    Variants nest ONLY here. On a patch an array is a data-loss
                    shape either way it is read, so edits go through the variant
                    routes.
                  items:
                    type: object
                    required: [price_minor, currency_code]
                    additionalProperties: false
                    properties:
                      title:
                        type: string
                      sku:
                        type: string
                      price_minor:
                        type: integer
                        minimum: 0
                        description: "Required on create. Minor units of the store's base currency. Absent is refused rather than treated as zero, because a zero price takes the free checkout path."
                      currency_code:
                        type: string
                        description: "Required on create, and an assertion rather than a choice: it must equal the store's base currency or the write is refused with 409 currency_mismatch."
                      manage_inventory:
                        type: boolean
                        description: "Defaults to true."
                      allow_backorder:
                        type: boolean
                        description: "Defaults to false."
                      requires_shipping:
                        type: boolean
                        description: "Defaults to true. False is what makes a variant digital."
                      variant_rank:
                        type: integer
                      selections:
                        type: object
                        additionalProperties: { type: string }
                        description: "Maps each of the product's option titles to this variant's value, and must cover them exactly."
                images:
                  type: array
                  items:
                    type: object
                    required: [url]
                    additionalProperties: false
                    properties:
                      url:
                        type: string
                      alt_text:
                        type: [string, "null"]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        type: string
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}:
    delete:
      summary: Delete a product
      operationId: delete_products_by_productId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products:delete
      x-required-scopes: [write_products, write_products:delete]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_products and then on write_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a product
      operationId: get_products_by_productId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                      created_at:
                        type: string
                      description:
                        nullable: true
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        nullable: true
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              nullable: true
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        nullable: true
                      meta_description:
                        nullable: true
                      meta_title:
                        nullable: true
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        nullable: true
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        nullable: true
                      thumbnail:
                        nullable: true
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Color:
                                  type: string
                                Size:
                                  type: string
                            sku:
                              nullable: true
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a product
      operationId: patch_products_by_productId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Every value is an absolute target, so replaying converges. A field
                sent as null CLEARS the column where the type allows null; a field
                omitted is left alone. Options, variants and images are separate
                resources with their own routes, and an array here is refused.
              properties:
                title:
                  type: string
                handle:
                  type: string
                  description: "Required on create, unlike the dashboard. Without it a retry outside the idempotency window mints a SECOND product at a suffixed handle; with it the retry collides and returns 409 handle_taken."
                subtitle:
                  type: [string, "null"]
                description:
                  type: [string, "null"]
                thumbnail:
                  type: [string, "null"]
                meta_title:
                  type: [string, "null"]
                meta_description:
                  type: [string, "null"]
                attributes:
                  type: object
                  additionalProperties: true
                  description: "Merchant-defined JSON, stored unchanged and unvalidated."
                discountable:
                  type: boolean
                  description: "Defaults to true when omitted."
                unpublish_at:
                  type: [string, "null"]
                  format: date-time
                material:
                  type: [string, "null"]
                weight:
                  type: [string, "null"]
                  description: "A decimal string, not a number, matching the column and the read shape."
                length:
                  type: [string, "null"]
                height:
                  type: [string, "null"]
                width:
                  type: [string, "null"]
                hs_code:
                  type: [string, "null"]
                mid_code:
                  type: [string, "null"]
                origin_country:
                  type: [string, "null"]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        nullable: true
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/attributes:
    put:
      summary: Replace a product's merchant attributes
      operationId: put_products_by_productId_attributes
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attributes]
              additionalProperties: false
              properties:
                attributes:
                  type: object
                  additionalProperties: true
                  description: >-
                    The complete attribute document, not a patch. Every key you
                    omit is removed, which is why this is a PUT on its own route
                    rather than a field on the product patch: the method is what
                    says the omission was deliberate. Send {} to clear them all.
                    Replaying the same body converges, so a retry cannot compound.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        type: string
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/form:
    delete:
      summary: Deactivate a product's form
      operationId: delete_products_by_productId_form
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. Touches only is_active; the schema and every
                revision are left exactly as they were. A ONE-WAY DOOR FROM THIS API
                ALONE: nothing published here can turn a form back on, because the
                write that would is the one refused above. The dashboard can always
                reactivate it; nothing is destroyed.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve product form
      operationId: get_products_by_productId_form
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      product_id:
                        type: string
                      schema:
                        type: object
                        properties:
                          fields:
                            type: array
                            items:
                              type: object
                              properties:
                                name:
                                  type: string
                                title:
                                  type: string
                                type:
                                  type: string
                          version:
                            type: number
                      schema_version:
                        type: number
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/form/revisions:
    get:
      summary: List a product's revisions
      operationId: get_products_by_productId_form_revisions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: string }
        - name: offset
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        schema:
                          type: object
                          properties:
                            fields:
                              type: array
                              items:
                                type: object
                                properties:
                                  name:
                                    type: string
                                  title:
                                    type: string
                                  type:
                                    type: string
                            version:
                              type: number
                        schema_version:
                          type: number
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/form/revisions/{version}:
    get:
      summary: Retrieve a form revision
      operationId: get_products_by_productId_form_revisions_by_version
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: version
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      schema:
                        type: object
                        properties:
                          fields:
                            type: array
                            items:
                              type: object
                              properties:
                                name:
                                  type: string
                                title:
                                  type: string
                                type:
                                  type: string
                          version:
                            type: number
                      schema_version:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/images:
    post:
      summary: Append images to a product
      operationId: post_products_by_productId_images
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [images]
              additionalProperties: false
              description: >-
                APPENDS. Each image takes the product's next free rank, and rank is
                not settable. NOT idempotent by shape: a replay appends a second
                copy, and the Idempotency-Key is what prevents that.
              properties:
                images:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [url]
                    additionalProperties: false
                    properties:
                      url:
                        type: string
                      alt_text:
                        type: [string, "null"]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        alt_text:
                          type: string
                        id:
                          type: string
                        rank:
                          type: integer
                        url:
                          type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/images/{imageId}:
    delete:
      summary: Remove an image from a product
      operationId: delete_products_by_productId_images_by_imageId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products:delete
      x-required-scopes: [write_products, write_products:delete]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: imageId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_products and then on write_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/publish:
    post:
      summary: Publish a product, now or at a scheduled time
      operationId: post_products_by_productId_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An empty body publishes immediately. Every parameter is in the
                body and never the query string: the idempotency key hashes
                method, path and body, so ?force=true and no query at all would
                hash identically and the flag would be silently discarded on a
                replay.
              properties:
                publish_at:
                  type: [string, "null"]
                  format: date-time
                  description: "Schedules the product to go live later. Absent publishes now."
                force:
                  type: boolean
                  description: >-
                    Acknowledges the refusal raised when a product's variants are
                    partly free and partly priced. Never a default.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        type: string
                      status:
                        type: string
                      subtitle:
                        type: string
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/rating:
    get:
      summary: Retrieve product rating
      deprecated: true
      description: >-
        DEPRECATED ALIAS. This returns a payload BYTE-IDENTICAL to
        GET /api/v1/products/{productId}/reviews/summary, which is the canonical route:
        it nests under the resource the numbers describe, the way the rest of this API
        nests sub-resources, and its name says what the body is.
        NOTHING IS CHANGING HERE. Both paths are served by ONE handler, so the bodies are
        identical by construction rather than by convention, and a published path cannot
        be withdrawn on this API. Existing integrations keep working indefinitely. Point
        NEW work at /reviews/summary so there is one spelling to reason about.
      operationId: get_products_by_productId_rating
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      average_rating:
                        type: number
                      distribution:
                        type: object
                        properties:
                          1:
                            type: number
                          2:
                            type: number
                          3:
                            type: number
                          4:
                            type: number
                          5:
                            type: number
                      facet_summary:
                        type: object
                      media_count:
                        type: integer
                      product_id:
                        type: string
                      review_count:
                        type: integer
                      verified_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/restore:
    post:
      summary: Restore a deleted product
      operationId: post_products_by_productId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products:delete
      x-required-scopes: [write_products, write_products:delete]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Brings back a
                product removed by DELETE with its variants, prices, images and
                reviews intact, because deleting hides it rather than destroying it.
                Answers 409 if another product has taken its handle in the meantime.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: number
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        nullable: true
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        nullable: true
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: number
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_products and then on write_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/reviews:
    get:
      summary: List a product's reviews
      operationId: get_products_by_productId_reviews
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_name:
                          type: string
                        body:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        pinned:
                          type: boolean
                        rating:
                          type: integer
                        title:
                          type: string
                        verified_purchase:
                          type: boolean
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/reviews/summary:
    get:
      summary: Retrieve review summary
      description: >-
        The canonical rating summary for one product: the maintained aggregate, so the
        body carries media_count, verified_count, facet_summary and ai_summary rather
        than a bare average and count.
        GET /api/v1/products/{productId}/rating is a DEPRECATED ALIAS of this route and
        returns the identical body from the same handler. Prefer this path.
      operationId: get_products_by_productId_reviews_summary
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      average_rating:
                        type: number
                      distribution:
                        type: object
                        properties:
                          1:
                            type: number
                          2:
                            type: number
                          3:
                            type: number
                          4:
                            type: number
                          5:
                            type: number
                      facet_summary:
                        type: object
                      media_count:
                        type: integer
                      product_id:
                        type: string
                      review_count:
                        type: integer
                      verified_count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/sales-channels:
    get:
      summary: List a product's sales channels
      operationId: get_products_by_productId_sales_channels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        is_default:
                          type: boolean
                        name:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a product's sales-channel membership
      operationId: put_products_by_productId_sales_channels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sales_channel_ids]
              additionalProperties: false
              description: >-
                REPLACES THE WHOLE SET, not a delta. sales_channel_ids is required BY
                NAME rather than defaulting to empty when omitted: an omitted field
                silently taking a product off the shop is exactly the surprise this
                API's additive-only rule elsewhere exists to prevent. Send an empty
                array to mean "sell nowhere", a real, expressible decision distinct
                from not sending the field at all. Every id must belong to the
                caller's own store; one that does not is 400 invalid_sales_channel.
              properties:
                sales_channel_ids:
                  type: array
                  items:
                    type: string
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        is_default:
                          type: boolean
                        name:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/unpublish:
    post:
      summary: Unpublish a product
      operationId: post_products_by_productId_unpublish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. It hides the
                product from the storefront and destroys nothing, which is the
                reversible operation this family offers in place of a delete.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: integer
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        type: string
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        type: [string, "null"]
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: integer
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/variants:
    post:
      summary: Add a variant to a product
      operationId: post_products_by_productId_variants
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [price_minor, currency_code]
              additionalProperties: false
              properties:
                title:
                  type: string
                sku:
                  type: string
                price_minor:
                  type: integer
                  minimum: 0
                  description: "Required on create. Minor units of the store's base currency. Absent is refused rather than treated as zero, because a zero price takes the free checkout path."
                currency_code:
                  type: string
                  description: "Required on create, and an assertion rather than a choice: it must equal the store's base currency or the write is refused with 409 currency_mismatch."
                manage_inventory:
                  type: boolean
                  description: "Defaults to true."
                allow_backorder:
                  type: boolean
                  description: "Defaults to false."
                requires_shipping:
                  type: boolean
                  description: "Defaults to true. False is what makes a variant digital."
                variant_rank:
                  type: integer
                selections:
                  type: object
                  additionalProperties: { type: string }
                  description: "Maps each of the product's option titles to this variant's value, and must cover them exactly."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allow_backorder:
                        type: boolean
                      currency_code:
                        type: string
                      id:
                        type: string
                      manage_inventory:
                        type: boolean
                      price_minor:
                        type: integer
                      requires_shipping:
                        type: boolean
                      selections:
                        type: object
                        properties:
                          Size:
                            type: string
                      sku:
                        type: string
                      title:
                        type: string
                      variant_rank:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/variants/{variantId}:
    delete:
      summary: Delete a product variant
      operationId: delete_products_by_productId_variants_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products:delete
      x-required-scopes: [write_products, write_products:delete]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_products and then on write_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a variant
      operationId: patch_products_by_productId_variants_by_variantId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Option selections and currency_code are absent deliberately.
                Selections are fixed at creation, and the price column carries the
                store's base currency with no per-currency dimension.
              properties:
                title:
                  type: string
                sku:
                  type: [string, "null"]
                price_minor:
                  type: integer
                  minimum: 0
                  description: "Required on create. Minor units of the store's base currency. Absent is refused rather than treated as zero, because a zero price takes the free checkout path."
                manage_inventory:
                  type: boolean
                  description: "Defaults to true."
                allow_backorder:
                  type: boolean
                  description: "Defaults to false."
                requires_shipping:
                  type: boolean
                  description: "Defaults to true. False is what makes a variant digital."
                variant_rank:
                  type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allow_backorder:
                        type: boolean
                      currency_code:
                        type: string
                      id:
                        type: string
                      manage_inventory:
                        type: boolean
                      price_minor:
                        type: integer
                      requires_shipping:
                        type: boolean
                      selections:
                        type: object
                        properties:
                          Size:
                            type: string
                      sku:
                        type: string
                      title:
                        type: string
                      variant_rank:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/products/{productId}/variants/{variantId}/restore:
    post:
      summary: Restore a deleted product variant
      operationId: post_products_by_productId_variants_by_variantId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products:delete
      x-required-scopes: [write_products, write_products:delete]
      parameters:
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Returns the whole
                product, so the restored variant is visible back in its option set.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attributes:
                        type: object
                        properties:
                          care:
                            type: string
                          fabric:
                            type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      discountable:
                        type: boolean
                      handle:
                        type: string
                      height:
                        nullable: true
                      hs_code:
                        type: string
                      id:
                        type: string
                      images:
                        type: array
                        items:
                          type: object
                          properties:
                            alt_text:
                              type: string
                            id:
                              type: string
                            rank:
                              type: number
                            url:
                              type: string
                      length:
                        nullable: true
                      material:
                        type: string
                      meta_description:
                        type: string
                      meta_title:
                        type: string
                      mid_code:
                        nullable: true
                      options:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            title:
                              type: string
                            values:
                              type: array
                              items:
                                type: string
                      origin_country:
                        type: string
                      published_at:
                        nullable: true
                      status:
                        type: string
                      subtitle:
                        nullable: true
                      thumbnail:
                        type: string
                      title:
                        type: string
                      type_id:
                        nullable: true
                      unpublish_at:
                        nullable: true
                      updated_at:
                        type: string
                      variants:
                        type: array
                        items:
                          type: object
                          properties:
                            allow_backorder:
                              type: boolean
                            currency_code:
                              type: string
                            id:
                              type: string
                            manage_inventory:
                              type: boolean
                            price_minor:
                              type: integer
                            requires_shipping:
                              type: boolean
                            selections:
                              type: object
                              properties:
                                Size:
                                  type: string
                            sku:
                              type: string
                            title:
                              type: string
                            variant_rank:
                              type: number
                      weight:
                        nullable: true
                      width:
                        nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_products and then on write_products:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_products, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns:
    get:
      summary: List promotion campaigns
      operationId: get_promotion_campaigns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        budget_limit:
                          type: integer
                        budget_remaining:
                          type: [integer, "null"]
                        budget_type:
                          type: string
                        budget_used:
                          type: integer
                        campaign_identifier:
                          type: string
                        created_at:
                          type: string
                        currency_code:
                          type: [string, "null"]
                        description:
                          type: [string, "null"]
                        ends_at:
                          type: [string, "null"]
                        id:
                          type: string
                        name:
                          type: string
                        starts_at:
                          type: [string, "null"]
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a promotion campaign
      operationId: post_promotion_campaigns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, campaign_identifier, budget_type, budget_limit]
              additionalProperties: false
              description: >-
                A campaign is the shared budget and window a group of discount codes
                draws against. campaign_identifier and budget_type are settable HERE AND
                NOWHERE ELSE: both are fixed at creation, and the patch refuses them by
                name.
                budget_limit is REQUIRED rather than defaulted, because 0 means UNCAPPED.
                A client that omitted the key would own a campaign that can spend without
                bound and read a 201 as success.
                THE CAMPAIGN IS CREATED ACTIVE, unlike a discount. It is an empty cap
                until a promotion is attached, and attaching is a second call, so creating
                it disabled would mean every first attach produced a promotion capped by a
                budget that is switched off.
                A retry outside the idempotency window collides on the unique (store,
                campaign_identifier) index and answers 409 campaign_exists rather than
                creating a second budget.
              properties:
                name:
                  type: string
                  description: "Required on create. What a merchant recognises the budget by in their own dashboard. Never shown to a shopper."
                campaign_identifier:
                  type: string
                  description: "Required on create. The campaign's permanent handle, uppercased on the way in and unique per store, so a retry outside the idempotency window answers 409 campaign_exists rather than creating a second budget. It cannot be changed afterwards."
                budget_type:
                  type: string
                  enum: [spend, usage]
                  description: "Required on create. Says whether budget_limit and budget_used are money in minor units of the store's base currency, or a count of redemptions. Fixed at creation: the running total has already accumulated in one unit, so switching would reinterpret it rather than convert it."
                budget_limit:
                  type: integer
                  minimum: 0
                  description: "Required on create. ZERO MEANS UNCAPPED, not a campaign that can spend nothing, which is why an omitted key is refused rather than defaulted. Lowering it below budget_used on a patch stops every linked discount at checkout immediately and tells nobody."
                description:
                  type: string
                  description: "Merchant notes. Never shown to a shopper."
                starts_at:
                  type: string
                  format: date-time
                  description: "Before this instant the budget guard refuses every draw, so linked discounts do not apply. Omit, or send null on a patch, for no lower bound."
                ends_at:
                  type: string
                  format: date-time
                  description: "After this instant the budget guard refuses every draw. Omit, or send null on a patch, to let the campaign run indefinitely."
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budget_limit:
                        type: integer
                      budget_remaining:
                        type: [integer, "null"]
                      budget_type:
                        type: string
                      budget_used:
                        type: integer
                      campaign_identifier:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        nullable: true
                      description:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      name:
                        type: string
                      starts_at:
                        nullable: true
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns/{campaignId}:
    get:
      summary: Retrieve a promotion campaign
      operationId: get_promotion_campaigns_by_campaignId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budget_limit:
                        type: integer
                      budget_remaining:
                        type: [integer, "null"]
                      budget_type:
                        type: string
                      budget_used:
                        type: integer
                      campaign_identifier:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      description:
                        type: [string, "null"]
                      ends_at:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      starts_at:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a promotion campaign
      operationId: patch_promotion_campaigns_by_campaignId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                Every value is an absolute target, so replaying converges. An omitted key
                keeps its stored value; an explicit null on description, starts_at or
                ends_at REMOVES it, which is the only way to make a campaign that ends on
                a date run indefinitely.
                LOWERING budget_limit BELOW budget_used STOPS THE CAMPAIGN DEAD and
                announces nothing. The guard compares the two on every draw, so the next
                redemption of every linked promotion is refused at checkout and shoppers
                holding a code simply stop getting the discount.
                status is not settable here; use the enable and disable routes.
                campaign_identifier and budget_type are refused by name, because both are
                fixed at creation and budget_used has already accumulated in one unit.
              properties:
                name:
                  type: string
                  description: "Required on create. What a merchant recognises the budget by in their own dashboard. Never shown to a shopper."
                budget_limit:
                  type: integer
                  minimum: 0
                  description: "Required on create. ZERO MEANS UNCAPPED, not a campaign that can spend nothing, which is why an omitted key is refused rather than defaulted. Lowering it below budget_used on a patch stops every linked discount at checkout immediately and tells nobody."
                description:
                  type: [string, "null"]
                  description: "Merchant notes. Never shown to a shopper."
                starts_at:
                  type: [string, "null"]
                  format: date-time
                  description: "Before this instant the budget guard refuses every draw, so linked discounts do not apply. Omit, or send null on a patch, for no lower bound."
                ends_at:
                  type: [string, "null"]
                  format: date-time
                  description: "After this instant the budget guard refuses every draw. Omit, or send null on a patch, to let the campaign run indefinitely."
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budget_limit:
                        type: integer
                      budget_remaining:
                        type: [integer, "null"]
                      budget_type:
                        type: string
                      budget_used:
                        type: integer
                      campaign_identifier:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      description:
                        type: [string, "null"]
                      ends_at:
                        nullable: true
                      id:
                        type: string
                      name:
                        type: string
                      starts_at:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns/{campaignId}/disable:
    post:
      summary: Disable a promotion campaign
      operationId: post_promotion_campaigns_by_campaignId_disable
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The reversible
                operation this family offers in place of a delete: it stops every linked
                promotion drawing on the budget and destroys nothing, and enable puts it
                back.
                SILENT ON THE MONEY PATH by design. The budget guard refuses a draw
                against a campaign that is not active, so a shopper holding a linked code
                stops receiving the discount with no notification anywhere.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budget_limit:
                        type: integer
                      budget_remaining:
                        type: [integer, "null"]
                      budget_type:
                        type: string
                      budget_used:
                        type: integer
                      campaign_identifier:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      description:
                        type: [string, "null"]
                      ends_at:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      starts_at:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns/{campaignId}/enable:
    post:
      summary: Enable a promotion campaign
      operationId: post_promotion_campaigns_by_campaignId_enable
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Absolute despite being
                a POST: enabling an enabled campaign leaves it enabled and answers 200, so
                a replay converges. A schedule goes on
                PATCH /promotion-campaigns/{campaignId}.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budget_limit:
                        type: integer
                      budget_remaining:
                        type: [integer, "null"]
                      budget_type:
                        type: string
                      budget_used:
                        type: integer
                      campaign_identifier:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: [string, "null"]
                      description:
                        type: [string, "null"]
                      ends_at:
                        type: [string, "null"]
                      id:
                        type: string
                      name:
                        type: string
                      starts_at:
                        type: [string, "null"]
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns/{campaignId}/promotions:
    get:
      summary: List a campaign's promotions
      operationId: get_promotion_campaigns_by_campaignId_promotions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_discounts
      x-required-scopes: [read_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        campaign_id:
                          type: [string, "null"]
                        code:
                          type: string
                        id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Put a discount under a campaign budget
      operationId: post_promotion_campaigns_by_campaignId_promotions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [promotion_id]
              additionalProperties: false
              description: >-
                Links a discount to this campaign, which is what puts it under the
                budget. Until this is called a campaign is a cap with nothing drawing on
                it.
                IT MOVES A DISCOUNT THAT IS ALREADY IN ANOTHER CAMPAIGN. A discount has at
                most one campaign, so there is no additive form of this call; the response
                carries campaign_id so you can see where it ended up. What does not move is
                spend: draws the discount already made stay counted against the campaign it
                made them under.
                200 rather than 201, because nothing is created. One field on an existing
                discount is set, and re-sending the same pair converges.
              properties:
                promotion_id:
                  type: string
                  description: >-
                    The id GET /api/v1/discounts serves. Not the code, which is editable
                    and only unique within one store.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      campaign_id:
                        type: [string, "null"]
                      code:
                        type: string
                      id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/promotion-campaigns/{campaignId}/promotions/{promotionId}/detach:
    post:
      summary: Take a discount out of a campaign budget
      operationId: post_promotion_campaigns_by_campaignId_promotions_by_promotionId_detach
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_discounts
      x-required-scopes: [write_discounts]
      parameters:
        - name: campaignId
          in: path
          required: true
          schema: { type: string }
        - name: promotionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The discount keeps
                working and simply stops drawing on this budget; attaching again restores
                the link exactly, which is why this is a POST and not a DELETE.
                A discount that is not in THIS campaign is 409 promotion_not_in_campaign
                rather than a silent success, because it may well be in a different one
                and a 200 would tell a caller who typed the wrong campaign id that a link
                has been removed while the real one keeps drawing on the real budget.
                The response is the membership reference with campaign_id null, which is
                the proof the link is gone.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      campaign_id:
                        nullable: true
                      code:
                        type: string
                      id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_discounts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/redirects:
    get:
      summary: List redirects
      operationId: get_redirects
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_redirects
      x-required-scopes: [read_redirects]
      parameters:
        - name: from_path
          in: query
          required: false
          schema: { type: string }
          description: The redirect registered for one source path.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Redirects touched at or after this RFC3339 instant. A redirect's target is
            repointed as a storefront is reorganised, and a stale copy sends shoppers to a
            page that has moved again. created_at never changes after the redirect is first
            written, so the default ordering says nothing about that.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        from_path:
                          type: string
                        id:
                          type: string
                        source:
                          type: string
                        status_code:
                          type: integer
                        to_path:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_redirects scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a redirect from a retired URL
      operationId: post_redirects
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_redirects
      x-required-scopes: [write_redirects]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_path, to_path]
              additionalProperties: false
              description: >-
                Creates ONE redirect, always recorded with source 'manual'. source is
                refused BY NAME rather than accepted: it is the only signal separating
                a bulk-imported row from a hand-made one, and a caller able to label
                its own rows 'shopify_import' would destroy the question the column
                exists to answer. The provenance keys (id, store_id, created_at,
                updated_at) are refused by name too, because the server mints them and
                your api key already names the store, and so are the wrapper shapes
                redirect and redirects, because the body IS the redirect and there is
                no bulk create on this api.
                FROM_PATH IS IMMUTABLE ONCE STORED, and that shapes how a mistake is
                repaired. PATCH /api/v1/redirects/{redirectId} moves to_path and
                status_code and refuses from_path by name, so a source path typed
                wrongly is fixed by DELETE /api/v1/redirects/{redirectId} followed by a
                fresh create, which mints a NEW id. store_redirect carries no
                deleted_at, so that delete removes the row for good.
                IT DOES NOT CONVERGE OUTSIDE THE IDEMPOTENCY WINDOW. Replaying the
                Idempotency-Key returns the recorded 201 without re-running anything,
                but a retry with a FRESH key meets UNIQUE (store_id, from_path) and
                answers 409 redirect_exists rather than upserting. That is what makes a
                migration safe to re-run: the second run reports its collisions instead
                of silently rewriting a target the merchant corrected by hand in
                between.
                A null value is refused on every key, since no column on a redirect is
                nullable and null could not mean "clear it". The whole body is capped at
                8192 bytes.
              properties:
                from_path:
                  type: string
                  maxLength: 2048
                  description: >-
                    Required. The old URL this store must keep answering. NORMALIZED
                    BEFORE IT IS STORED and before the uniqueness check: any scheme,
                    host, QUERY STRING and fragment are dropped, a leading slash is
                    forced and a trailing slash is trimmed except at the root, so
                    "https://old.example/legacy/" and "legacy" both store as "/legacy".
                    Matching is on the path alone, so two sources differing only in
                    their query string are the SAME row and the second one is a 409
                    rather than a second redirect. The 2048 cap is measured in bytes,
                    not characters, against the value you send.
                to_path:
                  type: string
                  maxLength: 2048
                  description: >-
                    Required. Where that URL now answers. TWO TARGET FORMS ARE ACCEPTED
                    and one is refused, and the difference is an open-redirect one this
                    table has a history with. Accepted: a store-relative path, which is
                    normalized exactly like from_path, so "new-page" stores as
                    "/new-page"; and an absolute http:// or https:// URL, which is
                    stored VERBATIM on ANY host, deliberately, because retiring a page
                    to another domain is a real merchant case. Refused with 400
                    invalid_redirect: a value beginning "//" or "/\", the
                    protocol-relative form, which targets another host while looking
                    like a path, so a consumer checking for an "http" prefix cannot tell
                    it apart from a store-relative target. That form is the one thing
                    this route refuses that the dashboard still accepts, so a row
                    carrying it can exist from another writer even though no call here
                    can create one. A value carrying any other scheme (javascript:,
                    data:, mailto:) is NOT treated as an external target: it fails the
                    http(s) test, takes a leading slash and is stored as an ordinary
                    path. from_path and to_path must differ AFTER normalization, since a
                    redirect pointing at its own source is a loop.
                status_code:
                  type: integer
                  enum: [301, 302]
                  description: >-
                    Optional, defaults to 301, which is the right default for the
                    migration this family exists to serve: 301 consolidates ranking
                    signals onto the target and 302 deliberately does not. STRICT HERE,
                    unlike the dashboard, which collapses anything that is not an
                    explicit 302 into 301. 307 and 308, both real redirect statuses
                    another platform may have exported, are a 400 naming the two allowed
                    values rather than a 201 describing a 301 the caller never asked for
                    and could not notice.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_path:
                        type: string
                      id:
                        type: string
                      source:
                        type: string
                      status_code:
                        type: integer
                      to_path:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_redirects scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/redirects/{redirectId}:
    delete:
      summary: Remove a redirect permanently
      description: >-
        Removes the redirect. IT TAKES NO BODY AT ALL, unlike the other writes on this
        api: the row is named entirely by the path and nothing is read from the request.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_redirects alone, and the split is what this family is the plainest case
        for: a nightly importer that upserts is repaired by the next run, and one that
        deletes is not.
        NOTHING FAILS AT THE MOMENT IT HAPPENS, which is the whole hazard. The resource
        IS the repair for a dead link, so removing it restores the breakage: the old
        path 404s again, every inbound link and search result pointing at it dies, and
        the only signal is organic traffic falling off a week later. There is no
        deleted_at, no tombstone and no restore, and for a migrated store rebuilding
        means knowing which paths the store used to serve, which is thousands of rows
        nobody has a second copy of. No money and no stock move, and nothing is emitted.
        WHY IT EXISTS ANYWAY: from_path is immutable and the PATCH refuses it by name,
        so this is the only route to a corrected source path. Delete, then create the
        redirect again with the from_path you meant, which mints a new id.
        IDEMPOTENCY HERE IS PER KEY, NOT PER RESOURCE. Replaying the same
        Idempotency-Key returns the stored 204 without touching anything. A FRESH key
        against a redirect that is already gone is 404, because "your retry already
        landed" and "there is no such redirect" are different facts, and a 204 for a
        mistyped id would let a reconciliation record the row as removed while the real
        redirect kept answering.
      operationId: delete_redirects_by_redirectId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_redirects:delete
      x-required-scopes: [write_redirects, write_redirects:delete]
      parameters:
        - name: redirectId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_redirects and then on write_redirects:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_redirects, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a redirect
      operationId: get_redirects_by_redirectId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_redirects
      x-required-scopes: [read_redirects]
      parameters:
        - name: redirectId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_path:
                        type: string
                      id:
                        type: string
                      source:
                        type: string
                      status_code:
                        type: integer
                      to_path:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_redirects scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Repoint a redirect at a new target
      operationId: patch_redirects_by_redirectId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_redirects
      x-required-scopes: [write_redirects]
      parameters:
        - name: redirectId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                THE REPAIR ROUTE FOR AN IMPORT THAT LANDED WRONG. A migration writes
                thousands of rows in one pass and some fraction of them point somewhere
                the merchant did not mean, because the target came out of another
                platform's export. Two fields are mutable and the body must name at
                least one of them.
                FROM_PATH IS REFUSED BY NAME rather than ignored, and it is the field an
                integrator reaches for first. It is the redirect's identity and half of
                UNIQUE (store_id, from_path), and the update statement does not touch
                it, so a patch that accepted the key and dropped it would answer 200
                having done nothing and the client would conclude this API is eventually
                consistent. Changing a source path is DELETE
                /api/v1/redirects/{redirectId} plus a fresh create, which mints a NEW id,
                so anything you keyed on the old one has to be updated too. source is
                refused for the create's reason: it separates an imported row from a
                hand-made one and a caller that could label its own rows would destroy
                the question the column answers. The provenance keys (id, store_id,
                created_at, updated_at) are refused because the server owns them and the
                path already names the row, and so are the wrapper shapes redirect and
                redirects, because the body IS the redirect and there is no bulk update
                on this api.
                AN EMPTY BODY IS A 400, not a no-op. The update sets updated_at
                unconditionally, so an empty patch would move the one column an
                integrator syncs on while changing nothing observable, and answer 200 for
                it.
                IT CONVERGES, unlike the create beside it. Both values are absolute
                rather than deltas, so a retry with a FRESH key past the idempotency
                window sets the same row to the same values and answers 200 again. There
                is no second row to mint here, because the row is named by the path.
                NO MONEY AND NO STOCK MOVE. What moves is where a retired URL sends
                shoppers and where search engines pass ranking signals, and it moves
                IMMEDIATELY: the storefront looks a path up when the request arrives and
                nothing in this module caches the row, so the next visitor follows the
                new target. Nothing is emitted, because this family registers no webhook
                topics, so a client mirroring redirects must poll.
                A null is refused on both keys, since no column on a redirect is
                nullable and null could not mean "clear it". The whole body is capped at
                8192 bytes, and an id naming no redirect in this store is a generic 404.
              properties:
                to_path:
                  type: string
                  maxLength: 2048
                  description: >-
                    Where this URL now answers, validated EXACTLY as on the create,
                    which is the security half of this route. Accepted: a store-relative
                    path, normalized so "new-page" stores as "/new-page"; and an
                    absolute http:// or https:// URL, stored VERBATIM on ANY host,
                    deliberately, because retiring a page to another domain is a real
                    merchant case. Refused with 400 invalid_redirect: a value beginning
                    "//" or "/\", the protocol-relative form, which targets another host
                    while looking like a path. Admitting it here would reopen through the
                    update path the hole the create refuses, and on the MORE valuable
                    row: an existing redirect is already indexed, already linked, and
                    nothing about it looks new. The loop check compares the normalized
                    value against this redirect's STORED from_path, which cannot be
                    changed, so a target equal to it is 400 invalid_redirect and nothing
                    is written. Omit the key to leave the target alone.
                status_code:
                  type: integer
                  enum: [301, 302]
                  description: >-
                    Omitting it KEEPS THE STORED VALUE, which is the one place this route
                    differs from the create, where an absent status_code means 301.
                    Defaulting here would silently demote to 301 every 302 whose target a
                    client updated, and it would do it on the one field an SEO audit
                    reads. 307 and 308 are a 400 naming the two allowed values rather
                    than a 200 describing a 301 the caller never asked for.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_path:
                        type: string
                      id:
                        type: string
                      source:
                        type: string
                      status_code:
                        type: integer
                      to_path:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_redirects scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/refund-reasons:
    get:
      summary: List refund reasons
      description: >-
        The store's refund reason catalogue: the vocabulary every refund on this store
        is categorised against, and what a refund's reason id resolves to.
        ONE LIST COVERS BOTH QUESTIONS a client asks. ?is_active=true is the set a
        merchant still offers on a new refund; ?is_active=false is what they have
        retired; omitting it returns both, which is what you want when resolving the
        reason on a historical refund, because a retired reason stays attached to every
        refund that already cites it.
        PAGED BY CREATION TIME, NOT BY code. code reads first in the dashboard, but a
        merchant can rename one, and a cursor over an editable column would drop or
        repeat reasons the moment they did. Sort on code yourself once you hold the
        page.
        created_by IS NOT PUBLISHED. The row records the merchant user who authored the
        reason for their own audit trail; it is an internal identifier, and it is empty
        on every reason written through this API, since a key has no user.
      operationId: get_refund_reasons
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_refund_reasons
      x-required-scopes: [read_refund_reasons]
      parameters:
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
          description: >-
            true narrows the page to the reasons the merchant still offers on a new
            refund, false to the ones they have retired. Omit it for both, which is
            what you want when resolving the reason on a historical refund: a retired
            reason stays attached to every refund that already cites it. A value that
            is not a boolean is a 400 rather than an ignored filter, because a client
            handed every reason in the store would offer a withdrawn one back to an
            agent.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Reasons touched at or after this RFC3339 instant. is_active is the field that
            MOVES on this resource: a reason is created once and then switched on and off as
            the returns policy changes, so the default created_at ordering is frozen after
            creation and a client caching this list to build a returns form keeps offering a
            retired reason. Supplying it CHANGES THE ORDER of this endpoint: the default walk
            descends created_at, this one climbs updated_at, which is the only ordering in
            which a keyset walk over a mutable column cannot skip a row. A reason edited
            mid-walk is handed to you again, so the walk is AT LEAST ONCE. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant. A cursor belongs to ONE ordering and is refused by the
            other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                        created_at:
                          type: string
                        description:
                          type: [string, "null"]
                        id:
                          type: string
                        is_active:
                          type: boolean
                        label:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_refund_reasons scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a refund reason
      operationId: post_refund_reasons
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_refund_reasons
      x-required-scopes: [write_refund_reasons]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, code]
              additionalProperties: false
              description: >-
                Adds one entry to the store's refund reason catalogue. code is UNIQUE
                per store, so re-running an import against the same store is answered
                409 refund_reason_exists rather than creating a second entry meaning the
                same thing. id, store_id, created_by, created_at and updated_at are
                refused by name rather than ignored: created_by records the merchant
                user who authored the reason in their own audit trail and is never taken
                from a body.
              properties:
                label:
                  type: string
                  description: >-
                    What the merchant's staff see when they pick a reason on a refund.
                    Required and trimmed; blank is a 400.
                code:
                  type: string
                  description: >-
                    Your stable handle for this reason, unique per store. Required.
                    Match on this rather than on id when reconciling a catalogue, since
                    it is the half you control.
                description:
                  type: [string, "null"]
                  description: >-
                    An optional longer note. Omit it or send null for none; there is no
                    other spelling, since an empty string is stored as null.
                is_active:
                  type: boolean
                  description: >-
                    Whether the reason is offered on a new refund. OMITTED MEANS true,
                    which is the only place on this family where it defaults: a merchant
                    adding a reason is adding one they intend to use. It is required on
                    the replace, where an omission would retire a live reason instead.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      is_active:
                        type: boolean
                      label:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_refund_reasons scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/refund-reasons/{refundReasonId}:
    delete:
      summary: Delete a refund reason
      operationId: delete_refund_reasons_by_refundReasonId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_refund_reasons:delete
      x-required-scopes: [write_refund_reasons, write_refund_reasons:delete]
      parameters:
        - name: refundReasonId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_refund_reasons and then on write_refund_reasons:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_refund_reasons, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a refund reason
      description: >-
        One refund reason, in the same shape the list and both writes serve.
        A reason that has been retired (is_active false) still reads here, which is
        what keeps the reason on a historical refund resolvable. Do not treat a 200 as
        "still offered": branch on is_active.
      operationId: get_refund_reasons_by_refundReasonId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_refund_reasons
      x-required-scopes: [read_refund_reasons]
      parameters:
        - name: refundReasonId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      is_active:
                        type: boolean
                      label:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_refund_reasons scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a refund reason
      operationId: put_refund_reasons_by_refundReasonId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_refund_reasons
      x-required-scopes: [write_refund_reasons]
      parameters:
        - name: refundReasonId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, code, is_active]
              additionalProperties: false
              description: >-
                REPLACES the reason, so an omitted description is CLEARED rather than
                kept. Read the reason first and send every field back.
                is_active IS REQUIRED FOR THAT REASON. Omitted it would decode to false
                and retire a reason the merchant is still using, with a 200 and nothing
                to say it happened: the reason disappears from their staff's picker on
                the next refund and their dashboard shows a shorter list rather than a
                fault.
                THIS IS ALSO THE ONLY WAY TO WITHDRAW A REASON. There is no DELETE on
                this family and there will not be one: a refund's reason link is ON
                DELETE SET NULL, so removing a reason silently strips the categorisation
                off every historical refund citing it, with no restore path. Send
                is_active false instead; the reason stops being selectable on new
                refunds and every refund that already cites it keeps its label.
              properties:
                label:
                  type: string
                  description: What the merchant's staff see. Required; blank is a 400.
                code:
                  type: string
                  description: >-
                    The reason's stable handle, unique per store. Required. Changing it
                    to one another reason already holds is 409 refund_reason_exists.
                is_active:
                  type: boolean
                  description: >-
                    Whether the reason is offered on a new refund. Required. false
                    retires it, which is reversible: send true to offer it again.
                description:
                  type: [string, "null"]
                  description: >-
                    The longer note. OMITTED OR NULL CLEARS IT, because this is a
                    replace. Send the note back to keep it.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: [string, "null"]
                      id:
                        type: string
                      is_active:
                        type: boolean
                      label:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_refund_reasons scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/regions:
    get:
      summary: List regions
      operationId: get_regions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_regions
      x-required-scopes: [read_regions]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Regions touched at or after this RFC3339 instant. A REGION DECIDES WHAT A SHOPPER
            IS CHARGED: its currency, its country set, and the tax and shipping behaviour
            keyed on it, so a client that misses an edit prices carts against a configuration
            the merchant has already changed. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, this one climbs updated_at, which
            is the only ordering in which a keyset walk over a mutable column cannot skip a
            row. A region edited mid-walk is handed to you again, so the walk is AT LEAST
            ONCE. The countries array is served on BOTH orderings. The bound is INCLUSIVE, so
            resuming from the newest updated_at you hold re-delivers the rows sharing that
            instant. A cursor belongs to ONE ordering and is refused by the other. NOTE that
            region_country carries no timestamp of its own, so moving a country between
            regions does not move either region in this walk; only editing the region does.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        automatic_taxes:
                          type: boolean
                        countries:
                          type: array
                          items:
                            type: object
                            properties:
                              country_code:
                                type: string
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_regions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Open a market
      operationId: post_regions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_regions
      x-required-scopes: [write_regions]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, currency_code]
              additionalProperties: false
              description: >-
                Creates one market: a named group of countries and the currency shoppers in
                them are charged in. Until this route existed a key could read the merchant's
                market map and not draw on it, so opening a market meant a human in the
                dashboard.
                THERE IS NO UPDATE AND NO DELETE ON A REGION, and neither absence is a staging
                decision. A DELETE is refused outright because order.region_id is ON DELETE
                SET NULL, so removing a market silently strips every historical order of the
                market it was sold into, and tax_class_rate.region_id is ON DELETE CASCADE, so
                it destroys every tax rate drawn on that market at the same time. There is no
                deleted_at and no restore for either loss, and apiscope mints no
                write_regions:delete for such a route to name. A PATCH is withheld separately,
                because re-denominating a live market is the one operation here that changes
                what a shopper is charged while nothing fails: prices resolve against the new
                currency and the storefront renders an entirely normal-looking number. Get the
                currency right on this call.
                IT DOES NOT CONVERGE PAST THE IDEMPOTENCY WINDOW. region carries no unique
                constraint on name or on anything else, so replaying the Idempotency-Key
                returns the recorded 201 while a retry with a FRESH key creates a SECOND
                market rather than colliding, and there is no delete to clean it up with.
                Reuse your own key on a retry.
                THE COUNTRY LIST IS ATOMIC WITH THE REGION. Every code is applied inside this
                call's own transaction, so a list naming one country that already belongs to
                another market fails the WHOLE create with 409 country_already_assigned rather
                than leaving a half-populated region for the merchant to find later.
                NO MONEY MOVES AND NO PRICE CHANGES, but what shoppers in these countries are
                QUOTED IN does: a region is what decides the currency at checkout. The
                storefront's cached region list is dropped as part of this call, so a new
                market is reachable immediately. Nothing is emitted: this family registers no
                webhook topics.
                currency, region, regions and country_code are refused BY NAME beside the
                provenance keys and is_default: the field is currency_code and it is a code
                rather than an object, the body IS the region and there is no bulk create, and
                the plural countries is where a list of codes goes. No key may be null,
                because no column on a region is nullable, and the whole body is capped at
                8192 bytes.
              properties:
                name:
                  type: string
                  description: >-
                    Required. The merchant's own market label ("Europe", "North America"), and
                    the only thing that makes a reg_01J... id legible in a UI. Blank or
                    whitespace is a 400 rather than an unlabelled market in the dashboard.
                currency_code:
                  type: string
                  description: >-
                    Required, and the field this whole family exists to answer. Three ASCII
                    letters, an ISO 4217 code, STORED AND RETURNED LOWERCASE, so "USD" reads
                    back "usd"; the same currency appears lowercase on orders, variants,
                    shipping options, returns and exchanges, so a client joining on the string
                    matches. A code this platform does not know is 400 unknown_currency, and
                    one the STORE has not enabled is 422 currency_not_supported, because the
                    body is fine and only the merchant's own configuration refuses it:
                    retrying identically would fail forever until they enable it.
                automatic_taxes:
                  type: boolean
                  description: >-
                    Optional, defaulting to TRUE, which is the column's own default. READ THIS
                    BEFORE BUILDING ON IT: nothing in this platform's tax module reads the
                    flag. It records the merchant's stated intent for the market, NOT a
                    guarantee that this platform computed the tax, so a tax integration that
                    treats true as "handled" and skips its own calculation will
                    under-collect.
                countries:
                  type: array
                  maxItems: 250
                  description: >-
                    The market's country membership, applied atomically with the create. Each
                    code is two ASCII letters, ISO 3166-1 alpha-2, stored and returned
                    lowercase; anything else is a 400. A code repeated within the list is a 400
                    naming it, rather than the 409 the unique index would produce, so your own
                    duplicate is never reported as another market's claim. A COUNTRY BELONGS
                    TO AT MOST ONE REGION PER STORE, so a code already assigned anywhere in
                    this store is 409 country_already_assigned and nothing is written. More
                    than 250 is 400 too_many_countries.
                    AN OMITTED OR EMPTY LIST IS NOT "no countries". The platform seeds the
                    currency's primary country best-effort, and SKIPS it silently when that
                    country already belongs to another market, so a second region in the same
                    currency can come back with countries: []. Read the response rather than
                    assuming your input was applied verbatim.
                  items: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      automatic_taxes:
                        type: boolean
                      countries:
                        type: array
                        items:
                          type: object
                          properties:
                            country_code:
                              type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_regions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/regions/{regionId}:
    get:
      summary: Retrieve a region
      operationId: get_regions_by_regionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_regions
      x-required-scopes: [read_regions]
      parameters:
        - name: regionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      automatic_taxes:
                        type: boolean
                      countries:
                        type: array
                        items:
                          type: object
                          properties:
                            country_code:
                              type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_regions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/regions/{regionId}/countries:
    post:
      summary: Move a country into a market
      operationId: post_regions_by_regionId_countries
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_regions
      x-required-scopes: [write_regions]
      parameters:
        - name: regionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country_code]
              additionalProperties: false
              description: >-
                THE OPERATION A KEY HOLDING THIS GRANT PERFORMS MOST OFTEN, and the only route
                on this API that changes which currency an existing set of shoppers is quoted
                in without touching a single price. Everyone shopping from that country moves
                onto this region's currency and tax treatment at their next request.
                200 WITH THE WHOLE REGION, not 201 with a membership object. The row created is
                a region-country assignment, and it is not addressable anywhere on this API: it
                has no GET, the read publishes only the code, and there is nothing to put in a
                Location header. The honest answer is the parent in the shape the reads already
                serve.
                ONE COUNTRY PER CALL. countries and country_codes are refused BY NAME, because
                a batch here would half-apply and then collide on the exclusivity rule and this
                family has no partial-failure contract to describe that. Send the whole list on
                POST /api/v1/regions, where it IS atomic, or call this route once per country.
                IT IS SAFE TO RETRY AND IT DOES NOT CONVERGE. Within the idempotency window a
                repeat returns the recorded 200. Past it, a repeat answers 409
                country_already_assigned, because the unique index fires whether the country is
                already in THIS region or in another one and the refusal cannot tell those
                apart. Treat a 409 as "this assignment exists somewhere" and read
                GET /api/v1/regions to find where.
                THERE IS NO DETACH ROUTE. Removing a country leaves it in NO market, which
                empties the storefront's country dropdown for those shoppers and leaves their
                carts with no tax region to resolve, and it is the half of this pair that
                cannot be made to converge, since a repeated removal would answer not-found.
                apiscope mints no write_regions:delete to gate one with. Moving a country to
                another market is this same route pointed at the other region.
                THE STOREFRONT'S CACHED REGION LIST IS NOT DROPPED, unlike on the create, so a
                membership change reaches shoppers when that cache expires rather than
                instantly. The dashboard behaves the same way, which is why the gap is one
                honest behaviour rather than two. Nothing is emitted either: this family
                registers no webhook topics.
                No money moves and no stock moves. The region's updated_at DOES advance, in the
                same transaction, so a client polling that column to sync incrementally sees
                the most operationally significant change this resource has.
                region_id is refused BY NAME because the region is in the path, and the
                provenance keys and is_default for the create's reasons. No key may be null.
              properties:
                country_code:
                  type: string
                  description: >-
                    Required. The ISO 3166-1 alpha-2 code of the country to add: two ASCII
                    letters, stored and returned lowercase, so "US" reads back "us". Anything
                    else is a 400. A region already holding 250 countries answers 400
                    too_many_countries, and a region id that names nothing in this store is a
                    generic 404.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      automatic_taxes:
                        type: boolean
                      countries:
                        type: array
                        items:
                          type: object
                          properties:
                            country_code:
                              type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_regions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reservations:
    get:
      summary: List reservations
      operationId: get_reservations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: location_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this location_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: variant_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this variant_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        location_id:
                          type: string
                        quantity:
                          type: number
                        status:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Place a manual hold against a variant's available stock
      operationId: post_reservations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, quantity]
              additionalProperties: false
              properties:
                variant_id:
                  type: string
                quantity:
                  type: integer
                  minimum: 1
                line_item_id:
                  type: string
                  description: >-
                    Optional. Names the cart or order line this hold backs; omitted
                    when the hold is placed ahead of a cart existing.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      line_item_id:
                        type: string
                      location_id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reservations/{reservationId}:
    get:
      summary: Retrieve a reservation
      operationId: get_reservations_by_reservationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: reservationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      location_id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/restock-alerts:
    get:
      summary: List restock alerts
      description: >-
        Every request to be told when a sold-out variant comes back, newest first. This is
        the record a restocking decision is made from, and NOTHING HAS EVER READ IT: the
        storefront writes a row when a shopper asks, the restock path claims the pending ones
        and mails them, and there is no admin route and no dashboard page, so "which sold-out
        variants have people waiting" was a database-console question until now.
        ONE LIST ANSWERS THREE QUESTIONS. ?variant_id= is "who is waiting on this", which is
        the purchasing question; ?status=pending is the live queue as opposed to the archive;
        ?email= is "what is this address waiting for", which is the shape a data-subject
        request arrives in. Send several to intersect them, or none for the store's whole
        waiting list, which is what a first sync wants.
        THE ALERT IS PER VARIANT, NEVER PER PRODUCT. A shopper waiting on one size is not
        waiting on the others, so a client that resolved variant_id up to its product would
        mail them about a restock they did not ask about.
        STATUS IS NOT TERMINAL. notified means this address has been told, but a repeat
        subscribe re-arms the same row back to pending and clears notified_at, because a
        shopper who wants to hear about the NEXT restock simply subscribes again. Treat
        notified as an archive you keep watching rather than as a closed record.
        AN ID OR ADDRESS THAT NAMES NOTHING IS AN EMPTY PAGE, not a 404, so this route is not
        a probe for which variant ids or which email addresses exist in the store. A ?status=
        outside the two values IS a 400, because that set is closed and a silently ignored
        status filter hands you the archive when you asked for the queue.
        PAGED BY CREATION TIME, which is the only column a re-arm never rewrites. Read
        created_at as "waiting since"; status and notified_at are what move.
        THIS FAMILY IS READ ONLY and there is no write half to add later. A row's whole
        purpose is to cause an outbound email from YOUR OWN sending domain the moment stock
        returns, so a caller-supplied address would be mail injection and would be
        indistinguishable afterwards from one a real shopper typed. Deleting a row is worse
        than silent: the shopper is simply never told and nothing reports it. Both stay on
        the storefront, where the person receiving the mail is the person asking for it, and
        apiscope mints no write_restock_alerts at all.
      operationId: get_restock_alerts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_restock_alerts
      x-required-scopes: [read_restock_alerts]
      parameters:
        - name: variant_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow the page to everyone waiting on one variant, which is the purchasing
            question and the reason to integrate at all. A VARIANT, not a product: a
            shopper waiting on one size is not waiting on the others. NOT validated
            against the catalogue, so a variant nobody is waiting on and a variant that
            does not exist both return an empty page rather than a 404, which is what
            stops this being a probe for which variant ids exist.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, notified] }
          description: >-
            pending is the live queue, notified is the archive. Omit it for both. A value
            outside the enum is a 400 rather than an unfiltered page, because a silently
            ignored status filter hands you the archive when you asked for the queue and
            you mail people who were already told.
            notified is NOT terminal: a repeat subscribe re-arms the row to pending.
        - name: email
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow the page to one address, which is what a data-subject request asks for.
            CASE IS FOLDED on both sides, so the mixed-case spelling a shopper typed
            matches the lowercased form this API stores and serves back. An address nobody
            subscribed with is an empty page, as with variant_id.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        email:
                          type: string
                        id:
                          type: string
                        notified_at:
                          type: [string, "null"]
                        status:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_restock_alerts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/restock-alerts/{restockAlertId}:
    get:
      summary: Retrieve a restock alert
      description: >-
        One restock alert, in the same shape the list serves, so a row read from a page can
        be re-read on its own rather than by walking the list again. That is how a client
        follows one request through to its send without re-syncing the collection.
        The id is the ALERT's, not the variant's and not the shopper's. An alert belonging to
        another store is a 404 here exactly as it is invisible in the list.
      operationId: get_restock_alerts_by_restockAlertId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_restock_alerts
      x-required-scopes: [read_restock_alerts]
      parameters:
        - name: restockAlertId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      email:
                        type: string
                      id:
                        type: string
                      notified_at:
                        type: [string, "null"]
                      status:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_restock_alerts scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/retail-locations:
    get:
      summary: List retail locations
      operationId: get_retail_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_retail_locations
      x-required-scopes: [read_retail_locations]
      parameters:
        - name: slug
          in: query
          required: false
          schema: { type: string }
          description: The location behind one storefront URL segment.
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
          description: Narrows to the locations currently trading, or the ones switched off.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Locations touched at or after this RFC3339 instant. is_active and the opening
            hours are the fields that MOVE: a store closes for a refit, hours change
            seasonally, and a locator built on a cached list sends shoppers to a shut door.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
            A DELETED LOCATION LEAVES THIS WALK SILENTLY, as it leaves the default list: it
            reports what changed, not what went away.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        address_line_1:
                          type: string
                        address_line_2:
                          type: [string, "null"]
                        city:
                          type: string
                        country_code:
                          type: string
                        created_at:
                          type: string
                        custom_fields:
                          type: object
                        email:
                          type: [string, "null"]
                        hours:
                          type: object
                        id:
                          type: string
                        is_active:
                          type: boolean
                        name:
                          type: string
                        phone:
                          type: [string, "null"]
                        position:
                          type: integer
                        postal_code:
                          type: [string, "null"]
                        region:
                          type: [string, "null"]
                        slug:
                          type: string
                        tags:
                          type: array
                          items:
                            {}
                        updated_at:
                          type: string
                        website_url:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_retail_locations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add a retail location to the store directory
      operationId: post_retail_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_retail_locations
      x-required-scopes: [write_retail_locations]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, name, address_line_1, city, country_code]
              additionalProperties: false
              description: >-
                The directory row only. The four geo fields (latitude, longitude,
                geocode_status, geocoded_at) are refused BY NAME rather than ignored:
                they record where THIS PLATFORM resolved the address to and when, so a
                supplied one would assert a geocode that never ran and nothing
                downstream could tell it from a real result. The map pin and the
                store-wide locator settings are refused for the same reason they are not
                published: they configure a rendered widget rather than describe a
                shopfront.
                The slug is unique per store, and a deleted location keeps holding its
                slug, so a retry past the idempotency window answers 409 slug_taken
                instead of minting a second shopfront at the same address.
              properties:
                slug:
                  type: string
                  description: >-
                    The storefront URL segment, lowercase kebab-case. Required rather
                    than derived from the name: this API does not invent an identifier a
                    merchant will see in a URL. It is MUTABLE, which is why the detail
                    route keys on the id.
                name:
                  type: string
                address_line_1:
                  type: string
                address_line_2:
                  type: [string, "null"]
                city:
                  type: string
                region:
                  type: [string, "null"]
                postal_code:
                  type: [string, "null"]
                country_code:
                  type: string
                  description: >-
                    Two letters. Stored and served EXACTLY as sent, with no case
                    normalisation anywhere in the write path.
                phone:
                  type: [string, "null"]
                email:
                  type: [string, "null"]
                website_url:
                  type: [string, "null"]
                hours:
                  type: object
                  description: >-
                    The merchant's opening-hours map, deliberately opaque: no schema, no
                    canonical key set, no validation. It publishes exactly what the
                    merchant typed rather than pretending to a structure the write path
                    never enforced. A consumer needing schema.org openingHours maps it.
                custom_fields:
                  type: object
                  description: >-
                    A free-form merchant-authored bag. It is safe to publish only while
                    every writer is merchant input, so internal state belongs in a new
                    field and never in here.
                tags:
                  type: array
                  items: { type: string }
                  description: The merchant's own locator filter chips.
                is_active:
                  type: boolean
                  description: >-
                    Defaults to true. false hides the location from the storefront
                    locator while keeping the row, and it is the REVERSIBLE withdrawal
                    that the delete qualifier exists so integrations do not need.
                position:
                  type: integer
                  description: >-
                    Display order for the locator. 0 is a real ordinal rather than an
                    absence, so a store that never reordered anything reads as all
                    zeroes.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      custom_fields:
                        type: object
                        properties:
                          parking:
                            type: string
                      email:
                        type: [string, "null"]
                      hours:
                        type: object
                        properties:
                          monday:
                            type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: [string, "null"]
                      position:
                        type: integer
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      slug:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      website_url:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_retail_locations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/retail-locations/{locationId}:
    delete:
      summary: Withdraw a retail location permanently
      operationId: delete_retail_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_retail_locations:delete
      x-required-scopes: [write_retail_locations, write_retail_locations:delete]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_retail_locations and then on write_retail_locations:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_retail_locations, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a retail location
      operationId: get_retail_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_retail_locations
      x-required-scopes: [read_retail_locations]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      custom_fields:
                        type: object
                        properties:
                          parking:
                            type: string
                      email:
                        type: [string, "null"]
                      hours:
                        type: object
                        properties:
                          monday:
                            type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: [string, "null"]
                      position:
                        type: integer
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      slug:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      website_url:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_retail_locations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Correct a retail location
      operationId: patch_retail_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_retail_locations
      x-required-scopes: [write_retail_locations]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                A PATCH, not the dashboard's full replace: an omitted key keeps its
                stored value. The six nullable fields (address_line_2, region,
                postal_code, phone, email, website_url) may be set to null to CLEAR
                them, which is a different claim from an empty string and is rendered
                differently by every consumer of this feed. A null on any other field is
                a 400 rather than a silent no-op.
                hours, custom_fields and tags REPLACE wholesale rather than merging, so
                a seasonal closure can delete one day rather than only add to it.
                The address is re-resolved on every patch, so a client changing several
                fields should send them in one call.
              properties:
                slug: { type: string }
                name: { type: string }
                address_line_1: { type: string }
                address_line_2: { type: [string, "null"] }
                city: { type: string }
                region: { type: [string, "null"] }
                postal_code: { type: [string, "null"] }
                country_code: { type: string }
                phone: { type: [string, "null"] }
                email: { type: [string, "null"] }
                website_url: { type: [string, "null"] }
                hours: { type: object }
                custom_fields: { type: object }
                tags:
                  type: array
                  items: { type: string }
                is_active: { type: boolean }
                position: { type: integer }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        type: [string, "null"]
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      custom_fields:
                        type: object
                        properties:
                          parking:
                            type: string
                      email:
                        type: [string, "null"]
                      hours:
                        type: object
                        properties:
                          monday:
                            type: string
                          sunday:
                            type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: [string, "null"]
                      position:
                        type: integer
                      postal_code:
                        type: [string, "null"]
                      region:
                        type: [string, "null"]
                      slug:
                        type: string
                      tags:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      website_url:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_retail_locations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/return-settings:
    get:
      summary: Retrieve return settings
      operationId: get_return_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      claim_window_days:
                        type: integer
                      return_window_days:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns:
    get:
      summary: List returns
      operationId: get_returns
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: The returns raised against one order.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Returns in one state. An unrecognised value is a 400 naming the accepted set.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Returns touched at or after this RFC3339 instant. A RETURN IS A WORKFLOW:
            requested, received, refunded or cancelled, and every one of those happens after
            the row is created. On the default ordering you learn a return was opened and never
            that money went back, which is the transition your books care about.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        canceled_at:
                          nullable: true
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_initiated:
                          type: boolean
                        customer_note:
                          nullable: true
                        id:
                          type: string
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              customer_note:
                                nullable: true
                              id:
                                type: string
                              order_line_item_id:
                                type: string
                              quantity:
                                type: integer
                        order_id:
                          type: string
                        received_at:
                          nullable: true
                        refund_amount_minor:
                          type: integer
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}:
    get:
      summary: Retrieve a return
      operationId: get_returns_by_returnId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_returns
      x-required-scopes: [read_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        nullable: true
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}/approve:
    post:
      summary: Approve a requested return
      operationId: post_returns_by_returnId_approve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        nullable: true
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}/cancel:
    post:
      summary: Cancel a return
      operationId: post_returns_by_returnId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        nullable: true
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}/receive:
    post:
      summary: Record that a return's goods arrived
      operationId: post_returns_by_returnId_receive
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        type: string
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}/refund:
    post:
      summary: Refund a received return
      operationId: post_returns_by_returnId_refund
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refund_to]
              additionalProperties: false
              description: >-
                One route for two destinations. The amount is not here and cannot be:
                it was computed when the return was created, so this grant chooses
                whether to pay and never how much. Every spelling of an amount, and a
                customer_id, are refused by name.
                Only a received return can be refunded; anything else is a 409 naming
                the transition. A replayed Idempotency-Key returns the stored response
                without paying again, and a NEW key on an already-refunded return is a
                409 rather than a second payout.
              properties:
                refund_to:
                  type: string
                  enum: [original_payment, store_credit]
                  description: >-
                    REQUIRED with no default, because money out of the wrong door cannot
                    be corrected by a second call. original_payment reverses the charge
                    and lands any tender-funded remainder as store credit, since a card
                    can only take back what it charged. store_credit credits the ORDER'S
                    own customer; a guest order has nobody to credit and is a 409 rather
                    than a silent fallback to the card.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        type: string
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/returns/{returnId}/reject:
    post:
      summary: Reject a requested return
      operationId: post_returns_by_returnId_reject
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_returns
      x-required-scopes: [write_returns]
      parameters:
        - name: returnId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canceled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_initiated:
                        type: boolean
                      customer_note:
                        nullable: true
                      id:
                        type: string
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            customer_note:
                              nullable: true
                            id:
                              type: string
                            order_line_item_id:
                              type: string
                            quantity:
                              type: integer
                      order_id:
                        type: string
                      received_at:
                        nullable: true
                      refund_amount_minor:
                        type: integer
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_returns scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-assignments/{assignmentId}:
    delete:
      summary: Unassign a review question set
      operationId: delete_review_assignments_by_assignmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: assignmentId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. No cascade to
                a shopper's answers: an answer already given does not name the
                assignment that surfaced its question, so removing the assignment
                changes nothing a shopper already said. Fully reversed by
                POST .../assignments with the same scope.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-groups:
    get:
      summary: List review groups
      operationId: get_review_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Groups touched at or after this RFC3339 instant. A REVIEW GROUP DECIDES WHICH
            PRODUCTS SHARE A RATING, so editing one changes the star count shown on every
            product in it, long after the group itself was created.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
            THE ONE CAVEAT: this tracks the GROUP row, and the membership join carries no
            timestamp of its own, so adding or removing a product does not move the group here
            unless the same write touches the parent. Reconcile membership from the group's
            members sub-list.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        updated_at:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a review group
      operationId: post_review_groups
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A pool that shares one set of reviews and one rating across several
                products (bundles or identical listings sold under different ids).
                Creating a group publishes nothing to a reader on its own: an empty
                group with no members changes no product's rating. Entitlement-gated
                on the Growth plan's review-groups feature; a store without it
                receives 403 feature_not_entitled.
              properties:
                name:
                  type: string
                  description: >-
                    Required. The merchant's own label, unique per store. A duplicate
                    name answers 409 name_taken.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-groups/{reviewGroupId}/members:
    get:
      summary: List a review group's members
      operationId: get_review_groups_by_reviewGroupId_members
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: reviewGroupId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        product_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add a product to a review group
      operationId: post_review_groups_by_reviewGroupId_members
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: reviewGroupId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id]
              additionalProperties: false
              description: >-
                Pools the named product's reviews and rating into the group. A
                product may belong to only one group; adding one already grouped
                answers 409 product_in_group. Idempotent for the same group: adding
                the same product to the group it is already in is a no-op. Fully
                reversed by DELETE .../members/{productId}.
              properties:
                product_id:
                  type: string
                  description: Required. The product to add.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-groups/{reviewGroupId}/members/{productId}:
    delete:
      summary: Remove a product from a review group
      operationId: delete_review_groups_by_reviewGroupId_members_by_productId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: reviewGroupId
          in: path
          required: true
          schema: { type: string }
        - name: productId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The product
                reverts to its own reviews and rating. Fully reversed by
                POST .../members with the same product id, which is what separates
                this from deleting the GROUP itself: that stays refused, because
                dissolving a group re-points every remaining member's rating at
                once with nothing announcing it, where removing one product at a
                time is an ordinary, reviewable edit.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-question-sets:
    get:
      summary: List review question sets
      operationId: get_review_question_sets
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        updated_at:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a review question set
      operationId: post_review_question_sets
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A named group of questions a shopper can be asked when reviewing a
                product. Creating a set publishes nothing to a shopper until it is
                assigned (POST .../assignments) and carries at least one question
                (POST .../questions). Entitlement-gated on the Growth plan's custom
                questions feature; a store without it receives 403
                feature_not_entitled.
              properties:
                name:
                  type: string
                  description: >-
                    Required. The merchant's own label for the set, shown only in
                    the dashboard.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-question-sets/{setId}:
    put:
      summary: Rename a review question set
      operationId: put_review_question_sets_by_setId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: setId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A RENAME ONLY. It cannot change the set's questions or
                assignments, which is what makes it safe where a full question
                replace (see the questions family) is refused: a name carries no
                answer-shaped data, so there is nothing here a shopper's past
                answer could be reinterpreted against.
              properties:
                name:
                  type: string
                  description: Required.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-question-sets/{setId}/assignments:
    get:
      summary: List a set's assignments
      operationId: get_review_question_sets_by_setId_assignments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: setId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        product_id:
                          type: string
                        scope:
                          type: string
                        set_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Assign a review question set to a target
      operationId: post_review_question_sets_by_setId_assignments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: setId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope]
              additionalProperties: false
              description: >-
                Binds a question set to every product (scope global), one category
                (scope category, with category_id), or one product (scope product,
                with product_id). A product scope validates the product exists in
                the store. Fully reversed by
                DELETE /review-assignments/{assignmentId}.
              properties:
                scope:
                  type: string
                  description: Required. One of global, category, product.
                category_id:
                  type: string
                  description: Required when scope is category; refused otherwise.
                product_id:
                  type: string
                  description: Required when scope is product; refused otherwise.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      scope:
                        type: string
                      set_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-question-sets/{setId}/questions:
    get:
      summary: List a set's questions
      operationId: get_review_question_sets_by_setId_questions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: setId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        aggregate_as_facet:
                          type: boolean
                        id:
                          type: string
                        is_required:
                          type: boolean
                        options:
                          type: array
                          items:
                            type: string
                        position:
                          type: integer
                        prompt:
                          type: string
                        set_id:
                          type: string
                        type:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add a question to a review question set
      operationId: post_review_question_sets_by_setId_questions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: setId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt, type]
              additionalProperties: false
              description: >-
                APPENDS a new question. It cannot change an existing one: there is
                deliberately no matching PUT on a single question, because a type
                change on a question that already has shopper answers would leave
                those answers interpreted against the wrong column, silently.
              properties:
                prompt:
                  type: string
                  description: Required. The text the shopper reads.
                type:
                  type: string
                  description: >-
                    Required. One of text, single_select, multi_select. Decides how
                    options and the stored answer are interpreted.
                options:
                  type: array
                  items:
                    type: string
                  description: >-
                    Required for single_select and multi_select, refused for text.
                    The choice list a shopper picks from.
                is_required:
                  type: boolean
                  description: Optional, defaults to false. Whether a shopper must
                    answer before submitting.
                position:
                  type: integer
                  description: >-
                    Optional, defaults to 0. The order the form presents questions
                    in.
                aggregate_as_facet:
                  type: boolean
                  description: >-
                    Optional, defaults to false. Whether this question's answers
                    roll into the product's published facet_summary.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      aggregate_as_facet:
                        type: boolean
                      id:
                        type: string
                      is_required:
                        type: boolean
                      options:
                        type: array
                        items:
                          type: string
                      position:
                        type: integer
                      prompt:
                        type: string
                      set_id:
                        type: string
                      type:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-queue:
    get:
      summary: List review queue
      description: >-
        The store's moderation queue: reviews held undecided, reviews already rejected, and
        the flags saying why each was held. This is the ONLY place on this API where a review
        that is not publicly visible appears. The two read_reviews lists serve approved rows
        only, in the SQL itself, so a held review is absent from them rather than merely
        unsorted, and no query parameter there could ever reach one.
        THESE ARE WORDS THE MERCHANT HAS NOT PUBLISHED, attached to a shopper's chosen display
        name, alongside a machine's judgement about them the shopper has never seen. Treat the
        body of a held review as unpublished content: do not syndicate it, do not show it to
        other customers, and do not assume rejecting it was a mistake.
        THERE IS NO DEFAULT FILTER. Omitting ?status= returns every status, which is what makes
        this usable for mirroring moderation state; pass ?status=pending for the queue proper.
        Only pending, approved and rejected are accepted, and anything else is a 400 rather
        than an empty page, so you cannot mistake a typo for an empty queue.
        IT BUYS DISCLOSURE, NOT AUTHORITY. Approving and rejecting is write_reviews, a
        different grant that this one does not imply and that does not imply this one; a tool
        that both shows the queue and clears it asks for both. Nothing here deletes a review,
        because that delete has no restore in any code path.
        NO MEDIA AND NO PRODUCT TITLE. The only media reader this platform has filters on
        approved reviews, so a held review's attachments are unreachable rather than empty, and
        product_id is flat because filling a nested reference would mean a join that silently
        drops a held review whose product was deleted, leaving it queued forever and invisible.
        Resolve the product through the products family.
        updated_at MEANS "LAST CHANGED", not "last moderated": a shopper editing their own
        review stamps it too.
      operationId: get_review_queue
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_review_moderation
      x-required-scopes: [read_review_moderation]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_name:
                          type: string
                        body:
                          type: string
                        created_at:
                          type: string
                        flagged_phrase:
                          type: boolean
                        flagged_phrase_term:
                          type: string
                        flagged_profanity:
                          type: boolean
                        id:
                          type: string
                        product_id:
                          type: string
                        rating:
                          type: integer
                        status:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                        verified_purchase:
                          type: boolean
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_review_moderation scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-queue/{reviewId}:
    get:
      summary: Retrieve a review queue
      description: >-
        One review in any status, which is the only way to read a review's status back on this
        API. Use it after approve or reject to confirm which state the write actually landed
        in, and after a reviews/created webhook to fetch the row that event announced.
        THE SAME SHAPE THE LIST SERVES, so a row taken off a page can be re-read on its own
        rather than by walking the collection again.
        A 404 IS DELIBERATELY GENERIC and covers four conditions at once: no such id, an id
        belonging to another store, an id whose review was deleted, and a malformed id.
        Separating them would turn this route into a probe for which review ids exist.
      operationId: get_review_queue_by_reviewId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_review_moderation
      x-required-scopes: [read_review_moderation]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      author_name:
                        type: string
                      body:
                        type: string
                      created_at:
                        type: string
                      flagged_phrase:
                        type: boolean
                      flagged_phrase_term:
                        type: string
                      flagged_profanity:
                        type: boolean
                      id:
                        type: string
                      product_id:
                        type: string
                      rating:
                        type: integer
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
                      verified_purchase:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_review_moderation scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-queue/{reviewId}/answers:
    get:
      summary: List a review's answers
      operationId: get_review_queue_by_reviewId_answers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_review_moderation
      x-required-scopes: [read_review_moderation]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        number:
                          nullable: true
                        options:
                          type: array
                          items:
                            {}
                        question_id:
                          type: string
                        question_prompt:
                          type: string
                        question_type:
                          type: string
                        text:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_review_moderation scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-request-settings:
    get:
      summary: Retrieve review request settings
      operationId: get_review_request_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      incentive_type:
                        type: string
                      incentive_value:
                        type: integer
                      media_incentive_type:
                        type: string
                      media_incentive_value:
                        type: integer
                      request_channel:
                        type: string
                      request_delay_days:
                        type: integer
                      requests_enabled:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-settings:
    get:
      summary: Retrieve review settings
      description: >-
        The store's review policy, and the context every other read in this family is
        missing. All of them serve approved reviews only, and what "approved" MEANS is set
        here: a store with auto_publish on publishes whatever a shopper writes, one with
        eligibility_mode purchase_gated publishes only verified buyers, and one with
        require_moderation on holds everything for a human. Read this before deciding how
        much weight to give a rating.
        syndicate_group_reviews IS THE FIELD THAT CHANGES HOW YOU READ THE REST. With it
        on, a review written on one product also appears on its group siblings and the
        rating is pooled across the group, so summing per-product counts double counts and
        one moderation call moves several products' visible ratings at once.
        A STORE THAT HAS NEVER SAVED SETTINGS ANSWERS 200 WITH THE DEFAULTS, not 404. The
        policy is in force either way, so a 404 would claim the store has no review policy
        when an unconfigured store moderates and does not auto-publish. updated_at is null
        there, and that is how you tell a saved policy from the fallback: there is no row,
        so there is no timestamp, and a zero date or a fresh now() would each state a fact
        the server does not have.
        blocked_phrases IS NOT PUBLISHED AND WILL NOT BE. It is the merchant's own
        moderation-evasion word list, so handing it over would hand over the enforcement
        boundary itself: anyone holding it knows precisely which wordings pass. If a
        future need is only "is screening configured", the additive field is a count, not
        the list.
        allow_media and allow_video let you distinguish "the merchant turned attachments
        off" from "nobody has uploaded any", which are different facts behind the same
        empty media array on a review.
        READ ONLY. Nothing on this API writes review settings, because auto_publish and
        require_moderation ARE the moderation policy: a key able to set them could turn
        moderation off and then publish anything through the ordinary shopper submit path.
        write_reviews buys clearing a queue, never redefining what the queue is for.
      operationId: get_review_settings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      allow_media:
                        type: boolean
                      allow_video:
                        type: boolean
                      auto_publish:
                        type: boolean
                      display_verified_badge:
                        type: boolean
                      eligibility_mode:
                        type: string
                      min_body_len:
                        type: integer
                      profanity_filter:
                        type: boolean
                      require_moderation:
                        type: boolean
                      syndicate_group_reviews:
                        type: boolean
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/review-tags:
    get:
      summary: List review tags
      operationId: get_review_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        color:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a review tag
      operationId: post_review_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A merchant label for organising reviews. Creates the tag only; it
                is not attached to any review until
                POST /reviews/{reviewId}/tags names it.
              properties:
                name:
                  type: string
                  description: >-
                    Required. Unique per store. A duplicate name answers 409
                    name_taken.
                color:
                  type: string
                  description: Optional. The dashboard's swatch for this label.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      color:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews:
    get:
      summary: List reviews
      operationId: get_reviews
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        author_name:
                          type: string
                        body:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        pinned:
                          type: boolean
                        product:
                          type: object
                          properties:
                            handle:
                              type: string
                            id:
                              type: string
                            title:
                              type: string
                        rating:
                          type: integer
                        title:
                          type: string
                        verified_purchase:
                          type: boolean
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/ratings:
    get:
      summary: List review ratings
      operationId: get_reviews_ratings
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        average_rating:
                          type: number
                        product_id:
                          type: string
                        review_count:
                          type: integer
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/{reviewId}/approve:
    post:
      summary: Approve a review
      operationId: post_reviews_by_reviewId_approve
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_reviews
      x-required-scopes: [write_reviews]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Publishes an
                existing shopper-authored review, recomputes the product's rating
                summary and purges the storefront cache. Idempotent by
                construction: the status is an absolute assignment from any current
                state and the summary is a full recompute, so a replay outside the
                idempotency window converges. Reversed by
                POST /reviews/{reviewId}/reject.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      product_id:
                        type: string
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/{reviewId}/reject:
    post:
      summary: Reject a review
      operationId: post_reviews_by_reviewId_reject
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_reviews
      x-required-scopes: [write_reviews]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. OVERLOADED, and
                a client cannot tell the two apart from the response: the same call
                declines a queued review and TAKES DOWN a live one, removing it
                from the storefront, from the product's public average and from the
                product-reviews feed, answering 200 either way. No v1 route
                publishes a review's status, so there is nothing to pre-check
                against. Reversed by POST /reviews/{reviewId}/approve; neither
                route can return a review to "never decided".
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      product_id:
                        type: string
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/{reviewId}/response:
    get:
      summary: Retrieve review response
      operationId: get_reviews_by_reviewId_response
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      review_id:
                        type: string
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/{reviewId}/tags:
    get:
      summary: List a review's tags
      operationId: get_reviews_by_reviewId_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_reviews
      x-required-scopes: [read_reviews]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        color:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_reviews scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Attach a tag to a review
      operationId: post_reviews_by_reviewId_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tag_id]
              additionalProperties: false
              description: >-
                Links an existing tag to the review. Idempotent: attaching a tag
                already on the review is a no-op. Fully reversed by
                DELETE /reviews/{reviewId}/tags/{tagId}.
              properties:
                tag_id:
                  type: string
                  description: Required.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/reviews/{reviewId}/tags/{tagId}:
    delete:
      summary: Detach a tag from a review
      operationId: delete_reviews_by_reviewId_tags_by_tagId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_review_policy
      x-required-scopes: [write_review_policy]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
        - name: tagId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Fully reversed
                by POST /reviews/{reviewId}/tags with the same tag id, which is
                what separates this from deleting the TAG ITSELF: that stays
                refused, because it cascades off every review that carried it with
                no record of which reviews those were.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_review_policy scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/sales-channels:
    get:
      summary: List sales channels
      operationId: get_sales_channels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_products
      x-required-scopes: [read_products]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        is_default:
                          type: boolean
                        name:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a sales channel
      operationId: post_sales_channels
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_products
      x-required-scopes: [write_products]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A new channel is NEVER the default: which channel the storefront
                serves is settled when a store first provisions one, and a second
                channel claiming it would leave the shop with two answers.
              properties:
                name:
                  type: string
                  description: >-
                    A checkbox label, 1 to 60 characters. Must be unique within the
                    store; two identically named channels would be two checkboxes a
                    merchant cannot tell apart.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      is_default:
                        type: boolean
                      name:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_products scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/segments:
    get:
      summary: List segments
      description: >-
        The store's named audiences, newest first.
        member_count IS AS STALE AS last_refreshed_at SAYS IT IS. A dynamic segment's
        size is whatever the last materialisation recorded, not a live count, so read
        the two together; a client that treats the number as "who matches right now"
        will report a figure the next refresh changes with nothing having gone wrong.
        ?kind and ?status narrow it, so "the audiences a campaign can target today" is
        one call rather than a client-side filter over every segment in the store.
        MEMBERSHIP IS NOT PUBLISHED. There is no route here that lists the contacts a
        segment resolves to: those are ids of real people and this API has no
        marketing-contact resource to resolve them against, so they would be
        unusable to you and readable by anyone holding read_segments.
      operationId: get_segments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_segments
      x-required-scopes: [read_segments]
      parameters:
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [static, dynamic] }
          description: >-
            Filter by how membership is decided. A dynamic segment selects its members
            from a rule; a static one holds a hand-curated list. This API creates
            dynamic segments only, so kind=dynamic is the set it can also edit.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [active, archived] }
          description: >-
            Filter by whether the segment is still maintained. Only an ACTIVE segment
            is picked up by the refresh sweeper, so status=active is the set whose
            member_count is being kept current.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Segments touched at or after this RFC3339 instant. A SEGMENT MOVES WITHOUT ANYONE
            EDITING IT: member_count and last_refreshed_at are rewritten by every refresh, and
            a dynamic segment refreshes on its own schedule, so on the default created_at
            ordering a recomputation is invisible and a cached audience keeps targeting a
            membership that has already changed. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, and this one climbs updated_at,
            which is the only ordering in which a keyset walk over a mutable column cannot
            skip a row. A segment refreshed mid-walk moves forward past your position and is
            returned again, so the walk is AT LEAST ONCE. The bound is INCLUSIVE, which
            matters here because a scheduled sweep touches many segments at nearly the same
            instant. A cursor belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        kind:
                          type: string
                        last_refreshed_at:
                          nullable: true
                        member_count:
                          type: integer
                        name:
                          type: string
                        rule:
                          type: [object, "null"]
                          properties:
                            field:
                              type: string
                            operator:
                              type: string
                            type:
                              type: string
                            value:
                              type: number
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a rule-defined audience
      description: >-
        Creates a rule-defined audience. It lands ACTIVE and UNMATERIALISED:
        member_count is 0 and last_refreshed_at is null until the background sweeper
        runs or you call the refresh route, so do not read the create's counts as the
        size of the audience.
        DYNAMIC ONLY. kind is refused by name, because a static segment's membership
        is a list of marketing contact ids and no route on this API can supply one; a
        static segment created here would be a permanently empty audience with no
        error ever returned.
        Names are unique per store, so a repeat is 409 name_taken.
      operationId: post_segments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_segments
      x-required-scopes: [write_segments]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, rule]
              additionalProperties: false
              properties:
                name:
                  type: string
                  description: >-
                    What the merchant sees in their own dashboard. Unique per store, so
                    a repeat is 409 name_taken rather than a second audience nobody can
                    tell apart.
                rule:
                  type: object
                  additionalProperties: true
                  description: >-
                    The selection tree, and what a dynamic segment IS. A node carries a
                    type of group, field or behavior. A GROUP node carries op (and, or,
                    not) and children. A FIELD node carries field, operator and value,
                    where field is one of orders_count, total_spent_minor, created_at,
                    last_order_at, last_open_at, last_click_at, last_activity_at,
                    email_consent, sms_consent or push_consent. A BEHAVIOR node carries
                    predicate (performed, not_performed), event, within_days and
                    min_count.
                    THE VOCABULARY IS CLOSED AND THE SERVER OWNS IT. An unrecognised
                    field, operator, consent state or event name is a 400 naming what
                    would have been accepted, not a rule that silently matches nobody.
                    value's type is decided by the field it sits under: a number for a
                    numeric field, a consent state string for a consent field, a list
                    for the in operator, a day count for a relative time window.
                    The tree may nest 8 levels and hold 200 nodes; past either it is
                    400 rule_too_complex.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      last_refreshed_at:
                        nullable: true
                      member_count:
                        type: integer
                      name:
                        type: string
                      rule:
                        type: [object, "null"]
                        properties:
                          field:
                            type: string
                          operator:
                            type: string
                          type:
                            type: string
                          value:
                            type: number
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/segments/preview:
    post:
      summary: Count the contacts a rule would select
      description: >-
        Counts the contacts a rule selects right now, without creating a segment and
        without materialising anything. This is how you check a rule is neither too
        narrow to be worth sending to nor too wide to be safe BEFORE it becomes an
        audience something can mail.
        IT RETURNS A COUNT AND NO CONTACT IDS. A preview answers whether a rule is
        the right size, not who is in it, and the people behind the number are
        personal data that read_segments is not a grant over. Reading contacts is
        what the marketing-contacts family is for and it costs
        read_marketing_contacts on top.
        GATED ON THE WRITE SCOPE even though it stores nothing. It is a dry run of an
        authoring step, and under a read grant it would be an arbitrary aggregate
        query engine over the store's contact database.
      operationId: post_segments_preview
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_segments
      x-required-scopes: [write_segments]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rule]
              additionalProperties: false
              properties:
                rule:
                  type: object
                  additionalProperties: true
                  description: >-
                    The tree to evaluate. Same shape and same closed vocabulary as the
                    create's, and it need not belong to a segment that exists. Nothing
                    is stored and no membership is materialised; only the count comes
                    back.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      count:
                        type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/segments/{segmentId}:
    delete:
      summary: Remove a segment and its membership
      description: >-
        Removes the segment. Its materialised membership and refresh history cascade
        away with it and nothing announces the removal, so anything targeting the
        segment simply stops having an audience. PATCH status archived is the
        reversible form of the same intent.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_segments alone, so ask the merchant for both.
        IDEMPOTENCY HERE IS PER KEY, NOT PER RESOURCE. Replaying the same
        Idempotency-Key returns the stored 204 without touching anything. A FRESH key
        against a segment that is already gone is 404 segment_not_found, because "your
        retry already landed" and "there is no such segment" are different facts.
      operationId: delete_segments_by_segmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_segments:delete
      x-required-scopes: [write_segments, write_segments:delete]
      parameters:
        - name: segmentId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_segments and then on write_segments:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_segments, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a segment
      description: >-
        One segment, static or dynamic. A static segment reads back with rule null:
        its membership is a hand-curated list rather than a selection, and this API
        neither creates nor edits one.
      operationId: get_segments_by_segmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_segments
      x-required-scopes: [read_segments]
      parameters:
        - name: segmentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      last_refreshed_at:
                        nullable: true
                      member_count:
                        type: integer
                      name:
                        type: string
                      rule:
                        type: [object, "null"]
                        properties:
                          field:
                            type: string
                          operator:
                            type: string
                          type:
                            type: string
                          value:
                            type: number
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Rename, archive or re-rule a segment
      description: >-
        Updates a segment in place, keeping its id. An omitted field keeps its stored
        value, which is why this is a PATCH: sending the whole resource would let an
        omitted status silently re-activate an audience the merchant had archived and
        put it back in the refresh rotation.
        A rule you send REPLACES the whole tree rather than merging into it, because
        half a boolean expression is a different expression. A rule sent to a static
        segment is 400 not_dynamic.
        status archived is the REVERSIBLE way to retire an audience: it leaves the
        rule and the history intact and only takes the segment out of the refresh
        rotation. It is what the delete's second grant exists to be compared against.
      operationId: patch_segments_by_segmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_segments
      x-required-scopes: [write_segments]
      parameters:
        - name: segmentId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value. None
                may be sent as null: no field on a segment has a "clear it" meaning, and
                a null rule on a dynamic segment is not "no rule", it is a segment that
                cannot exist.
              properties:
                name:
                  type: string
                  description: Still unique per store; a clash is 409 name_taken.
                status:
                  type: string
                  enum: [active, archived]
                  description: >-
                    archived takes the segment out of the refresh rotation without
                    destroying its definition, and is reversible. It is the safe
                    alternative to deleting.
                rule:
                  type: object
                  additionalProperties: true
                  description: >-
                    REPLACES the whole tree rather than merging into it. Same shape and
                    same closed vocabulary as the create. Sending one to a static
                    segment is 400 not_dynamic: a static segment's membership is a list
                    and it can never gain a rule.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      last_refreshed_at:
                        nullable: true
                      member_count:
                        type: integer
                      name:
                        type: string
                      rule:
                        type: [object, "null"]
                        properties:
                          field:
                            type: string
                          operator:
                            type: string
                          type:
                            type: string
                          value:
                            type: number
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/segments/{segmentId}/refresh:
    post:
      summary: Recompute a dynamic segment's membership
      description: >-
        Recomputes the segment's membership from its rule and answers with the
        segment, so member_count and last_refreshed_at come back as the transaction
        committed them.
        IT IS DESTRUCTIVE DESPITE THE NAME: the whole materialised membership is
        deleted and re-inserted in one transaction. A rule you narrowed a moment ago
        shrinks the audience anything targeting it is about to send to.
        Only a dynamic segment can be refreshed; a static one is 400 not_dynamic.
        USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key returns the stored
        response without re-running anything, so a second refresh on the same key
        answers with the first call's counts and recomputes nothing.
      operationId: post_segments_by_segmentId_refresh
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_segments
      x-required-scopes: [write_segments]
      parameters:
        - name: segmentId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The response is the
                segment as the refresh committed it, so member_count and
                last_refreshed_at are the numbers to read.
                USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key returns the
                stored response without re-running anything.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      kind:
                        type: string
                      last_refreshed_at:
                        type: [string, "null"]
                      member_count:
                        type: integer
                      name:
                        type: string
                      rule:
                        type: [object, "null"]
                        properties:
                          field:
                            type: string
                          operator:
                            type: string
                          type:
                            type: string
                          value:
                            type: number
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_segments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/seo:
    get:
      summary: List seo
      description: >-
        Which resources in this store carry an SEO override.
        AN EMPTY PAGE MEANS "this store has overridden nothing", NOT "this store has
        no SEO". Every product, collection, category and page still has its own title
        and description underneath, and a content page carries per-page meta on top of
        that which rides read_pages. This family publishes the OVERRIDE LAYER and
        nothing beneath it, so a client reading an empty page as "no SEO configured"
        will be wrong for most stores.
        A re-upsert MUTATES A ROW IN PLACE WITHOUT MOVING IT: created_at is never
        rewritten, so a full re-sync cannot shift your window mid-walk. The cost is the
        other half of the same fact: a walk keyed on created_at will not surface an
        override rewritten after the walk began, so reconcile on updated_at rather than
        assuming page order tracks recency.
      operationId: get_seo
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_seo
      x-required-scopes: [read_seo]
      parameters:
        - name: resource_type
          in: query
          required: false
          schema: { type: string, enum: [product, collection, category, page] }
          description: >-
            Restrict the page to one kind of resource. Omit it to receive all four. A
            value outside the enum is a 400, not an empty page, because "no override is
            of that kind" and "there is no such kind" are different facts and a client
            told the former stops looking.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        canonical_url:
                          type: string
                        created_at:
                          type: string
                        description:
                          type: string
                        no_index:
                          type: boolean
                        og_image_url:
                          type: string
                        resource_id:
                          type: string
                        resource_type:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_seo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/seo/{resourceType}/{resourceId}:
    delete:
      summary: Clear a resource's SEO override
      operationId: delete_seo_by_resourceType_by_resourceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_seo:delete
      x-required-scopes: [write_seo, write_seo:delete]
      parameters:
        - name: resourceType
          in: path
          required: true
          schema: { type: string }
        - name: resourceId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_seo and then on write_seo:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_seo, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a seo
      description: >-
        One resource's stored override, in the merchant's own words.
        NEVER THE LOCALISED TEXT the storefront renders, which is what makes this the
        correct read half of a read-modify-write against the PUT: what comes back here
        is exactly what a replayed PUT would write. Translated meta titles are read and
        written through /api/v1/translations.
        A resource with no override is 404, not a 200 carrying blanks. The two states
        are different and only one is reachable by a write: an override whose every
        field is blank EXISTS and suppresses nothing, while no override at all leaves
        the resource on its catalogue text.
      operationId: get_seo_by_resourceType_by_resourceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_seo
      x-required-scopes: [read_seo]
      parameters:
        - name: resourceType
          in: path
          required: true
          schema: { type: string }
        - name: resourceId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canonical_url:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      no_index:
                        type: boolean
                      og_image_url:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_seo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a resource's SEO override
      operationId: put_seo_by_resourceType_by_resourceId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_seo
      x-required-scopes: [write_seo]
      parameters:
        - name: resourceType
          in: path
          required: true
          schema: { type: string }
        - name: resourceId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [no_index]
              additionalProperties: false
              description: >-
                A FULL REPLACE, not a merge, which is why this is PUT and not PATCH.
                Every field you omit is CLEARED: there is no COALESCE anywhere in the
                upsert, so a title-only body wipes the description, the og image and
                the canonical url. Read the override first and send it back whole.
                The write is an UPSERT on the (resource_type, resource_id) pair in the
                path, so replaying the same body converges rather than accumulating,
                and it answers 200 on both the first write and every replay: the
                status never tells you whether an override already existed. A
                resource_id naming nothing is ACCEPTED rather than 404, because the
                column carries no foreign key to any catalogue table, which is what
                lets an import write SEO ahead of the products it describes.
                THE STOREFRONT CHANGES IMMEDIATELY. The override is read at render
                time and never snapshotted, so there is no draft, no preview and no
                publish step, and this family emits no webhook topic: a client
                reconciling has to poll.
                IT WRITES THE SOURCE LANGUAGE ONLY. A translated meta title is dropped
                once the source it recorded no longer matches what is stored here, so
                editing a title through this route silently disables its translations
                until they are rewritten through PUT /api/v1/translations, under the
                field names meta_title and meta_description on the same
                (resource_type, resource_id).
                LAST WRITE WINS. There is no If-Match and no version, so replaying an
                old body after a newer write reverts the row.
              properties:
                title:
                  type: [string, "null"]
                  maxLength: 255
                  description: >-
                    The meta title override. Omit it or send null to CLEAR it, which
                    returns the resource to its own catalogue title rather than
                    blanking anything. Empty string and null are the same stored
                    state, because the column is NOT NULL DEFAULT '' and there is no
                    distinct empty title to preserve.
                description:
                  type: [string, "null"]
                  maxLength: 500
                  description: >-
                    The meta description override. Omitting it clears it, exactly as
                    for title.
                og_image_url:
                  type: [string, "null"]
                  maxLength: 2048
                  description: >-
                    Absolute http(s) URL, or empty. A relative path is refused rather
                    than stored: it would be pasted into a head tag on a domain this
                    API does not know and would resolve somewhere nobody intended.
                canonical_url:
                  type: [string, "null"]
                  maxLength: 2048
                  description: >-
                    Absolute http(s) URL, or empty. Same validation as og_image_url.
                no_index:
                  type: boolean
                  description: >-
                    REQUIRED, and the only required field. true asks search engines
                    not to index the resource. It is required because an absent key
                    and an explicit false are indistinguishable once decoded, so
                    accepting the omission would let any title-only write put a
                    resource the merchant deliberately hid back into search results
                    with nothing on the storefront looking any different. It is the
                    tripwire that teaches you this route is a full replace before the
                    replace can cost you anything.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      canonical_url:
                        type: string
                      created_at:
                        type: string
                      description:
                        type: string
                      no_index:
                        type: boolean
                      og_image_url:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_seo scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipments:
    get:
      summary: List shipments
      operationId: get_shipments
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carriers
      x-required-scopes: [read_carriers]
      parameters:
        - name: order_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict the page to the shipments dispatched for one order. This is
            where the order-scoped list lives on this API; there is no
            /orders/{orderId}/shipments route, because "what has gone out since I
            last looked" cannot be asked through one without already knowing every
            order id.
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [created, label_purchased, in_transit, delivered, cancelled, exception] }
          description: >-
            Filter by the SHIPMENT's own state, which is not the order's fulfillment
            state: a store can record a shipment here and never touch a fulfillment,
            and the reverse. An unrecognised value is a 400 rather than an empty
            page, because "no shipment is in that state" and "there is no such state"
            are different facts and a client told the former concludes nothing has
            shipped and stops looking.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        carrier_code:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        order_id:
                          type: string
                        status:
                          type: string
                        tracking_number:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carriers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipments/{shipmentId}:
    get:
      summary: Retrieve a shipment
      operationId: get_shipments_by_shipmentId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carriers
      x-required-scopes: [read_carriers]
      parameters:
        - name: shipmentId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      carrier_code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      order_id:
                        type: string
                      status:
                        type: string
                      tracking_number:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carriers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipments/{shipmentId}/tracking-events:
    get:
      summary: List a shipment's tracking events
      operationId: get_shipments_by_shipmentId_tracking_events
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_carriers
      x-required-scopes: [read_carriers]
      parameters:
        - name: shipmentId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        description:
                          type: [string, "null"]
                        id:
                          type: string
                        occurred_at:
                          type: string
                        shipment_id:
                          type: string
                        status:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_carriers scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipping-options:
    get:
      summary: List shipping options
      operationId: get_shipping_options
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_shipping
      x-required-scopes: [read_shipping]
      parameters:
        - name: region_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            DESTINATION semantics, not equality. It returns the rates bound to that
            region PLUS the global ones, which is the set checkout would actually offer
            there. Plain equality would omit exactly the rates a shopper is most likely
            to be given, and a quote built from it would be too high or empty.
        - name: is_active
          in: query
          required: false
          schema: { type: boolean }
          description: >-
            Omit it for both. Only "true" and "false" are accepted: anything else is a
            400 rather than an ignored filter, because a client that asked for the live
            rates and silently received the disabled ones too has no way to tell.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Options touched at or after this RFC3339 instant. A SHIPPING OPTION DECIDES WHAT A
            SHOPPER IS CHARGED AT CHECKOUT: its price, its region and whether it is active, so
            a client caching delivery choices keeps offering one the merchant has repriced or
            switched off unless it can ask what changed. Supplying it CHANGES THE ORDER of this
            endpoint: the default walk descends created_at, and this one climbs updated_at,
            which is the only ordering in which a keyset walk over a mutable column cannot skip
            a row. An option edited mid-walk moves forward past your position and is returned
            again, so the walk is AT LEAST ONCE. The bound is INCLUSIVE, so resuming from the
            newest updated_at you hold re-delivers the rows sharing that instant. A cursor
            belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        is_active:
                          type: boolean
                        name:
                          type: string
                        region_id:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a shipping rate
      operationId: post_shipping_options
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping
      x-required-scopes: [write_shipping]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, amount_minor]
              additionalProperties: false
              description: >-
                amount_minor is an INTEGER in the store's default currency's minor
                units, and it is required rather than defaulted: a missing amount
                treated as 0 would mint a free shipping rate indistinguishable from a
                deliberate one. Send 0 for genuinely free shipping. There is no
                currency_code: a shipping option carries no currency of its own, and
                the amount is converted into the shopper's currency at cart time by
                the same code checkout charges through, so naming one here would
                mislabel the number rather than change it.
              properties:
                name:
                  type: string
                  description: >-
                    What the shopper sees beside the rate at checkout. Required and
                    non-blank.
                amount_minor:
                  type: integer
                  minimum: 0
                  maximum: 1000000000000
                  description: >-
                    The flat rate, in the store default currency's minor units. The
                    upper bound sits well below the integer maximum so an order total
                    built from it cannot overflow.
                is_active:
                  type: boolean
                  description: >-
                    Defaults to true. Setting it false stages a rate the merchant can
                    switch on later, and it is also the REVERSIBLE way to retire one.
                region_id:
                  type: [string, "null"]
                  description: >-
                    The destination region this rate applies to. Absent or null means
                    GLOBAL, offered everywhere, which is the only spelling for "any
                    destination". A region-bound rate is refused at every other
                    destination, including an unknown one.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      region_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipping-options/{shippingOptionId}:
    delete:
      summary: Remove a shipping rate permanently
      description: >-
        Removes the rate. There is no restore, and recreating is not restoring: the new
        row has a new id, and an order's shipping_option_name is a snapshot rather than
        a link, so nothing joins the old rate back.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_shipping alone. A deleted rate FAILS NOTHING: checkout simply stops
        offering that delivery method, shoppers in that region see fewer choices or
        none, and the only signal is a revenue line moving a week later. Use PATCH with
        is_active false to retire a rate reversibly; that needs only write_shipping.
        A missing option is a 404 rather than a silent 204, so a mistyped id cannot read
        as a successful removal while the merchant's real rate keeps being offered.
      operationId: delete_shipping_options_by_shippingOptionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping:delete
      x-required-scopes: [write_shipping, write_shipping:delete]
      parameters:
        - name: shippingOptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_shipping and then on write_shipping:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_shipping, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a shipping option
      operationId: get_shipping_options_by_shippingOptionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_shipping
      x-required-scopes: [read_shipping]
      parameters:
        - name: shippingOptionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      region_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Adjust a shipping rate
      operationId: patch_shipping_options_by_shippingOptionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping
      x-required-scopes: [write_shipping]
      parameters:
        - name: shippingOptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value, which is what a rate adjustment
                is: one number moves and the rest stays as the merchant left it. An
                EMPTY body is refused rather than treated as a no-op, because the merge
                still writes the row and would move updated_at, which is the field this
                resource publishes so you can sync incrementally.
              properties:
                name:
                  type: string
                  description: Non-blank. Omit the key to leave it unchanged.
                amount_minor:
                  type: integer
                  minimum: 0
                  maximum: 1000000000000
                is_active:
                  type: boolean
                  description: >-
                    false stops checkout offering the rate and is fully reversible. It
                    is what to use instead of the DELETE, which needs a second grant.
                region_id:
                  type: [string, "null"]
                  description: >-
                    Three states. Omit the key to leave the binding alone; send null to
                    CLEAR it and make the rate global again; send an id to bind it. An
                    empty string is refused, because it could mean either and guessing
                    wrong offers one region's rate at every destination on earth.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      region_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipping-profiles:
    get:
      summary: List shipping profiles
      operationId: get_shipping_profiles
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_shipping
      x-required-scopes: [read_shipping]
      parameters:
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [default, gift_card, custom] }
          description: >-
            Omit it for all three. A value outside this set is a 400 rather than an
            empty page: a client that wrote "giftcard" would otherwise conclude the
            merchant has no gift-card profile.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Profiles touched at or after this RFC3339 instant. A profile's name and type are
            edited as a merchant reorganises fulfilment, and every product points at one, so a
            stale copy mislabels the shipping behaviour of the whole catalogue.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        type:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a shipping profile
      operationId: post_shipping_profiles
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping
      x-required-scopes: [write_shipping]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                A profile groups products by what delivering them requires. The id this
                returns is what a product's shipping_profile_id refers to. created_by is
                not settable and is not recorded for an api key: who made a change comes
                from the authenticated principal, and a key is not a person.
              properties:
                name:
                  type: string
                  maxLength: 200
                  description: >-
                    Unique per store. A name already taken answers 409
                    profile_name_taken rather than 400: the body is well formed and
                    would have been accepted a moment earlier.
                type:
                  type: string
                  enum: [default, gift_card, custom]
                  description: Defaults to "default" when omitted.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shipping-profiles/{shippingProfileId}:
    delete:
      summary: Remove a shipping profile permanently
      description: >-
        Removes the profile. Every product filed under it is silently unfiled: nothing
        errors on either side, and rebuilding means re-creating the profile under a NEW
        id and re-filing every product by hand.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key holding
        write_shipping alone.
        A missing profile is a 404 rather than a silent 204.
      operationId: delete_shipping_profiles_by_shippingProfileId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping:delete
      x-required-scopes: [write_shipping, write_shipping:delete]
      parameters:
        - name: shippingProfileId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_shipping and then on write_shipping:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_shipping, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a shipping profile
      operationId: get_shipping_profiles_by_shippingProfileId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_shipping
      x-required-scopes: [read_shipping]
      parameters:
        - name: shippingProfileId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Rename or retype a shipping profile
      operationId: patch_shipping_profiles_by_shippingProfileId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_shipping
      x-required-scopes: [write_shipping]
      parameters:
        - name: shippingProfileId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value. An EMPTY body is refused rather
                than treated as a no-op, because the merge still writes the row and
                would move updated_at, which is the field you sync incrementally on.
              properties:
                name:
                  type: string
                  maxLength: 200
                type:
                  type: string
                  enum: [default, gift_card, custom]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      type:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_shipping scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/shop:
    get:
      summary: Retrieve shop
      operationId: get_shop
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_shop
      x-required-scopes: [read_shop]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      default_locale:
                        type: string
                      domain:
                        type: string
                      domain_verified:
                        type: boolean
                      id:
                        type: string
                      length_unit:
                        type: string
                      name:
                        type: string
                      timezone:
                        type: string
                      updated_at:
                        type: string
                      weight_unit:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_shop scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/staff-members:
    get:
      summary: List staff members
      description: >-
        The store's team, newest member first: who is a member, what role each one holds,
        and whether that membership is active.
        THIS IS WHAT RESOLVES THE USER IDS THIS API ALREADY HANDS YOU. assigned_to on a
        conversation and author_user_id on a message are store user ids and nothing else
        published here could turn one into a person, so an inbox rendering a thread showed a
        raw identifier. Sync this list once and you can name every actor in it.
        THE id IS THE USER ID, not a membership id. It is the same value assigned_to carries,
        the same value the conversation PUT accepts back, and it is stable across stores,
        because one person can be on more than one team.
        SUSPENDED AND INVITED MEMBERS ARE INCLUDED. Their ids still appear on conversations
        and messages you already hold, so hiding them would answer "no such member" for
        somebody plainly named on the thread you are rendering. Read status before you assign
        NEW work: only active grants access, so assigning to a suspended member gives it to
        someone who cannot log in to see it.
        first_name and last_name are BEST EFFORT and may be null. They come from a second
        lookup outside the membership record, and a failure there degrades to an unnamed
        member rather than failing the roster, so the id is always right and the name may be
        absent. Do not key anything on the name.
        NO EMAIL ADDRESS IS PUBLISHED. Every consumer of this resource takes a user id, and
        the address is additionally the input the merchant's own team-invite uses, so serving
        it here would make this grant a staff mailing list ranked by role. If you need to
        reach a teammate, do it through the merchant rather than through this API.
        NO FILTERS. A team is tens of rows, role and status arrive on every one of them, and
        a query parameter cannot be withdrawn once you have written against it.
        THIS FAMILY IS READ ONLY and there is no write half to add later. The role field is
        not an attribute of this resource, it IS the resource, so a write would promote a
        member, demote an owner, or suspend the one person able to undo either, from a key
        already acting inside the store. apiscope mints no write_staff at all. Team changes
        stay in the dashboard, where a human owner makes them.
      operationId: get_staff_members
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_staff
      x-required-scopes: [read_staff]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        first_name:
                          type: [string, "null"]
                        id:
                          type: string
                        last_name:
                          type: [string, "null"]
                        role:
                          type: string
                        status:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_staff scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/staff-members/{staffMemberId}:
    get:
      summary: Retrieve a staff member
      description: >-
        One team member, in the same shape the list serves, so an id read off a conversation
        can be resolved on its own rather than by paging the whole roster.
        THE PATH TAKES THE USER ID, which is this resource's own id and the value assigned_to
        and author_user_id carry. A membership record id is not accepted and is never
        published.
        THE 404 IS THE ANSWER, not merely the error case. "Is this id still a member of my
        store" is exactly the check to make before writing it to a conversation's
        assigned_to, where the same question is asked again and the write is refused. A user
        id belonging to another store is a 404 here, the same way that member is invisible in
        the list.
      operationId: get_staff_members_by_staffMemberId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_staff
      x-required-scopes: [read_staff]
      parameters:
        - name: staffMemberId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      first_name:
                        type: [string, "null"]
                      id:
                        type: string
                      last_name:
                        type: [string, "null"]
                      role:
                        type: string
                      status:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_staff scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-locations:
    get:
      summary: List stock locations
      operationId: get_stock_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Locations touched at or after this RFC3339 instant. An address or name is
            corrected after a location opens, and a client resolving an inventory level's
            location_id against a cached list keeps showing the old one. Pair it with the
            inventory-level walk, which is the highest-churn list on this API.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        address_line_1:
                          nullable: true
                        address_line_2:
                          nullable: true
                        city:
                          type: string
                        country_code:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        is_active:
                          type: boolean
                        name:
                          type: string
                        phone:
                          nullable: true
                        postal_code:
                          nullable: true
                        province:
                          nullable: true
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a stock location
      operationId: post_stock_locations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              description: >-
                Creates the warehouse a picker is told to go to. PUT /api/v1/inventory-levels
                has required a location_id since it shipped and nothing on this API could mint
                one, so a 3PL onboarding a new site had to stop, open the dashboard, type the
                address by hand and copy the id back into its own config.
                NO STOCK MOVES AND NO MONEY MOVES. A fresh location holds nothing at all:
                every quantity is a level, written afterwards with
                PUT /api/v1/inventory-levels, and until then this row is a name and an address.
                THERE IS NO DELETE ON THIS FAMILY, and it is refused rather than forgotten.
                inventory_level.location_id is ON DELETE CASCADE, so removing a location would
                silently destroy every stock record held there, with no soft delete and no
                restore; the existing guard is a COUNT, so a location whose levels were just
                zeroed would pass it. The reversible operation is published instead: PATCH with
                is_active false retires a warehouse, keeps every level resolvable, and is
                undone with one more patch. apiscope publishes no delete qualifier for this
                family to gate a DELETE with either.
                IT DOES NOT CONVERGE PAST THE IDEMPOTENCY WINDOW. stock_location has no unique
                index beyond its primary key, and that is correct rather than an oversight: two
                warehouses may legitimately share a name and even an address, since a mezzanine
                and a floor in one building are two locations to a picker. So a retry with a
                FRESH key creates a second location. That failure is recoverable in the safe
                direction, because a fresh location holds no levels and no stock: list, spot
                the duplicate, and have the merchant remove it from the dashboard with nothing
                having moved.
                Nothing is emitted: this family registers no webhook topics.
                THE ADDRESS IS FLAT, and a nested "address" object is refused BY NAME because
                the internal payload the dashboard consumes nests it and a client copying that
                shape would have every address key ignored and get a 201 for it. state, region
                and country are refused too: other commerce APIs spell them that way, and a
                warehouse address typed against the wrong vocabulary fails at the carrier
                rather than here.
              properties:
                name:
                  type: string
                  description: >-
                    Required, and it has no defensible default. Blank or whitespace is a 400
                    naming the key.
                is_active:
                  type: boolean
                  description: >-
                    Optional, defaulting to true. false creates a location that exists and is
                    not somewhere new stock is placed, which is the same retired state PATCH
                    produces. It may not be null.
                address_line_1:
                  type: string
                  description: >-
                    Optional. Every address field may be sent as null, which means this
                    warehouse has no recorded value for it, and an empty string is accepted as
                    the SAME request rather than refused, because a form with a cleared input
                    sends "". Both store SQL NULL, so the next read answers null rather than
                    "".
                address_line_2:
                  type: string
                city:
                  type: string
                province:
                  type: string
                  description: >-
                    The sub-national division. This resource spells it province; state and
                    region are refused by name.
                postal_code:
                  type: string
                country_code:
                  type: string
                  description: >-
                    The ISO 3166-1 alpha-2 code. NOT case-normalised on the way in, unlike
                    currency codes elsewhere on this API: the read publishes it exactly as
                    stored, so send it in the case you want to read back and be consistent
                    across your own writes.
                phone:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        type: string
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: string
                      postal_code:
                        type: string
                      province:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-locations/{locationId}:
    delete:
      summary: Delete a stock location
      operationId: delete_stock_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory:delete
      x-required-scopes: [write_inventory, write_inventory:delete]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_inventory and then on write_inventory:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_inventory, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a stock location
      operationId: get_stock_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        type: string
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: string
                      postal_code:
                        type: string
                      province:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update or retire a stock location
      operationId: patch_stock_locations_by_locationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                PATCH rather than the admin surface's PUT, and the method is the contract. That
                one is a full replace driven by a form that re-sends every field on every save;
                an integration that computed a partial body against it would blank the
                warehouse's street, city and phone and get a 200 for it. Here an omitted key
                keeps its stored value and clearing a column is the explicit act of sending
                null.
                THIS IS ALSO THE RETIRE ROUTE, because there is no DELETE on this family: send
                is_active false and the location stops being somewhere new stock is placed
                while every level already held there stays readable and resolvable, and one
                more patch undoes it. A DELETE would cascade every inventory_level at the
                location away with no restore, which is why the reversible form is the only one
                published.
                RETIRING A LOCATION MOVES NO STOCK. The quantities held there are unchanged and
                still readable at /api/v1/inventory-levels?location_id=, so a client totalling
                sellable stock must decide for itself whether to count a retired site. No money
                moves and nothing is emitted: this family registers no webhook topics.
                AN EMPTY PATCH IS A 400, not a no-op that still bumps updated_at. A client that
                sent nothing meant to send something.
                IT CONVERGES: every value is absolute, so replaying the same body with a fresh
                key past the idempotency window lands the same location in the same state.
                THE RESPONSE IS THE LOCATION RE-READ THROUGH THE READ PATH, so a cleared column
                comes back as null rather than as "", and the body is what
                GET /api/v1/stock-locations/{locationId} serves. A location id that resolves to
                nothing in this store is a generic 404.
                stocked_quantity, variant_id, inventory_levels, reserved_quantity, the nested
                address object, state, region, country and the provenance keys are refused BY
                NAME, exactly as on the create.
              properties:
                name:
                  type: string
                  description: >-
                    NOT NULL on the row, so it cannot be cleared: an explicit null is refused,
                    and a present-but-blank value is a 400 naming the key rather than a
                    nameless warehouse. Omit the key to leave it unchanged.
                is_active:
                  type: boolean
                  description: >-
                    false retires the location, true returns it to service. It may not be null,
                    and omitting it leaves the stored value alone.
                address_line_1:
                  type: [string, "null"]
                  description: >-
                    ABSENT AND NULL ARE DIFFERENT HERE, which is the whole of the patch
                    contract: an absent key keeps the stored value, an explicit null CLEARS the
                    column, and an empty string clears it the same way, because "" is stored as
                    SQL NULL. All seven address fields behave this way.
                address_line_2:
                  type: [string, "null"]
                city:
                  type: [string, "null"]
                province:
                  type: [string, "null"]
                postal_code:
                  type: [string, "null"]
                country_code:
                  type: [string, "null"]
                  description: >-
                    The ISO 3166-1 alpha-2 code, stored exactly as sent: nothing upper-cases or
                    lower-cases it, so the read answers the case you wrote.
                phone:
                  type: [string, "null"]
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      address_line_2:
                        nullable: true
                      city:
                        type: string
                      country_code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      name:
                        type: string
                      phone:
                        type: string
                      postal_code:
                        type: string
                      province:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-locations/{locationId}/address:
    get:
      summary: Retrieve stock location address
      operationId: get_stock_locations_by_locationId_address
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      city:
                        type: string
                      country_code:
                        type: string
                      postal_code:
                        type: string
                      province:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Set or update a stock location's ship-from address
      operationId: patch_stock_locations_by_locationId_address
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: locationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                UPSERT SEMANTICS: the first call for a location creates the address
                row, every later call merges the patch onto whatever is already
                stored. A field omitted from the body leaves the stored value
                unchanged; there is no separate create/update split to call. This
                mirrors the admin route it is published alongside exactly.
              properties:
                address_line_1:
                  type: string
                address_line_2:
                  type: string
                city:
                  type: string
                province:
                  type: string
                postal_code:
                  type: string
                country_code:
                  type: string
                  description: >-
                    Normalized to 2-letter uppercase on every write; anything else is
                    refused before it reaches the row.
                phone:
                  type: string
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      address_line_1:
                        type: string
                      city:
                        type: string
                      country_code:
                        type: string
                      phone:
                        type: string
                      postal_code:
                        type: string
                      province:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-transfers:
    get:
      summary: List stock transfers
      operationId: get_stock_transfers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [requested, completed, cancelled] }
          description: >-
            Filter by transfer state. An unrecognised value is a 400 rather than an
            empty page: "no transfer is in that state" and "there is no such state"
            are different facts, and a client told the former concludes its warehouse
            has no pending moves.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        from_location_id:
                          type: string
                        id:
                          type: string
                        quantity:
                          type: number
                        status:
                          type: string
                        to_location_id:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Request a stock transfer between locations
      operationId: post_stock_transfers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [variant_id, from_location_id, to_location_id, quantity]
              additionalProperties: false
              description: >-
                Records an INTENT and moves nothing: the transfer lands as requested
                and no inventory level changes until POST
                /stock-transfers/{transferId}/complete. Stock is not held or reserved
                in the meantime, so a completion can still fail for insufficient
                stock.
                NOT structurally idempotent, because a transfer has no natural key:
                the same variant may legitimately move between the same two locations
                twice in a day. A retry inside the idempotency window is deduplicated;
                one outside it creates a second transfer, which can be cancelled with
                no stock having moved.
              properties:
                variant_id:
                  type: string
                  description: >-
                    A transfer moves a VARIANT, not a product: two sizes of one shirt
                    are separate stock.
                from_location_id:
                  type: string
                to_location_id:
                  type: string
                  description: Must differ from from_location_id.
                quantity:
                  type: integer
                  description: Positive, at most 1000000.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_location_id:
                        type: string
                      id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      to_location_id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-transfers/{transferId}:
    get:
      summary: Retrieve a stock transfer
      operationId: get_stock_transfers_by_transferId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_inventory
      x-required-scopes: [read_inventory]
      parameters:
        - name: transferId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_location_id:
                        type: string
                      id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      to_location_id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-transfers/{transferId}/cancel:
    post:
      summary: Cancel a requested stock transfer
      operationId: post_stock_transfers_by_transferId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: transferId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Abandons a
                transfer that has not been completed. No stock has moved, so nothing
                is reversed.
                Replaying a cancel answers 200 with the transfer. Cancelling a
                COMPLETED transfer is 409 invalid_transition and there is no
                un-complete: a completed transfer is a movement that happened, and the
                honest reversal is a new transfer in the other direction.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_location_id:
                        type: string
                      id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      to_location_id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/stock-transfers/{transferId}/complete:
    post:
      summary: Complete a stock transfer and move the stock
      operationId: post_stock_transfers_by_transferId_complete
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_inventory
      x-required-scopes: [write_inventory]
      parameters:
        - name: transferId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. THIS is the call
                that moves stock, both legs in one transaction: the source level is
                decremented and the destination incremented by the transfer's own
                quantity. Partial completion does not exist in this model.
                Refuses with 409 insufficient_stock if the source cannot cover the
                quantity, and with 409 level_not_found unless both locations already
                stock the variant; in both cases nothing moved and the transfer is
                still requested. Replaying a completion answers 200 with the transfer
                and does NOT move the stock twice; completing a cancelled transfer is
                409 invalid_transition.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      from_location_id:
                        type: string
                      id:
                        type: string
                      quantity:
                        type: number
                      status:
                        type: string
                      to_location_id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_inventory scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-credit-accounts:
    get:
      summary: List store credit accounts
      operationId: get_store_credit_accounts
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_store_credit
      x-required-scopes: [read_store_credit]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        balance_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_store_credit scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-credit-accounts/{customerId}:
    get:
      summary: List a customer's store credit accounts
      operationId: get_store_credit_accounts_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_store_credit
      x-required-scopes: [read_store_credit]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        balance_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_store_credit scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-credit-accounts/{customerId}/transactions:
    get:
      summary: List a customer's transactions
      operationId: get_store_credit_accounts_by_customerId_transactions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_store_credit
      x-required-scopes: [read_store_credit]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        balance_after_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        customer_id:
                          type: string
                        id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_store_credit scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-currencies:
    post:
      summary: Add a currency the store can price and sell in
      operationId: post_store_currencies
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_currencies
      x-required-scopes: [write_currencies]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currency_code]
              additionalProperties: false
              description: >-
                is_default is refused BY NAME: accepting it would silently re-point
                every price list and region that resolves against "the store's default
                currency" without touching a row either of those resources own, the
                same re-denomination hazard write_regions' currency_code carries. The
                FIRST currency a store ever adds becomes its default automatically,
                which is not a choice this route makes and so is not refused.
              properties:
                currency_code:
                  type: string
                  description: A known ISO 4217 code, case-insensitive.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      currency_code:
                        type: string
                      is_default:
                        type: boolean
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_currencies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-currencies/{currencyCode}:
    delete:
      summary: Remove a currency from the store
      operationId: delete_store_currencies_by_currencyCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_currencies
      x-required-scopes: [write_currencies]
      parameters:
        - name: currencyCode
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. GUARDED, not just gated by scope: refused with
                409 cannot_delete_default while another currency remains and the code
                named is the store's default, and refused with 409 last_currency if it
                is the store's only remaining currency. Set a different default first
                to remove a former default currency.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_currencies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-policies:
    get:
      summary: List store policies
      description: >-
        The merchant's legal documents: refund, privacy, terms, shipping and cookie.
        BOUNDED AT FIVE AND NOT PAGED. policy_type is constrained to that fixed set, so
        the page block reads has_more false with no cursor, permanently. limit and
        after are listed below because every collection on this API carries them, and
        this route IGNORES BOTH: one call returns every policy the store has. There is
        no status filter either; filter five rows yourself.
        DRAFTS ARE INCLUDED, which the storefront's own policy read excludes. status
        draft means shoppers cannot currently see the document, so do not present one to
        a customer as the store's terms. That asymmetry is why this is its own grant
        rather than part of read_shop.
        A policy the merchant has never written is simply absent from the list. Expect
        fewer than five rows, and treat a missing type as "no policy of that kind"
        rather than as an error.
      operationId: get_store_policies
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_store_policies
      x-required-scopes: [read_store_policies]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        body:
                          type: string
                        created_at:
                          type: string
                        policy_type:
                          type: string
                        status:
                          type: string
                        title:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_store_policies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/store-policies/{policyType}:
    get:
      summary: Retrieve a store policy
      description: >-
        One policy, in the same shape the list serves. The path segment IS the
        policy_type: privacy, terms, refund, shipping or cookie.
        KEYED ON THE TYPE, NOT ON AN ID, and no id is published. policy_type is half the
        row's unique key so it never changes, which makes it safe to persist as your own
        reference.
        A type outside the five is 400 invalid_policy_type, because you mistyped a
        constant. A valid type the merchant has not written is 404, including one that
        exists only in another store, so this route cannot be used to probe for a
        policy you were not granted.
        An UNPUBLISHED policy answers 200 with status draft here rather than 404. Branch
        on status before showing it to anyone.
      operationId: get_store_policies_by_policyType
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_store_policies
      x-required-scopes: [read_store_policies]
      parameters:
        - name: policyType
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      policy_type:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_store_policies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Save a store policy
      operationId: put_store_policies_by_policyType
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_store_policies
      x-required-scopes: [write_store_policies]
      parameters:
        - name: policyType
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, status]
              additionalProperties: false
              description: >-
                REPLACES THE WHOLE DOCUMENT: title, body and status travel together
                every time, there is no partial-field patch. status is REQUIRED rather
                than defaulting to draft when omitted, unlike the dashboard form this
                mirrors, because an integrator's omitted field silently unpublishing a
                LIVE policy while checkout keeps running is exactly the surprise this
                API's additive-only rule elsewhere exists to prevent.
                EVERY SAVE IS ATTRIBUTED AND RECOVERABLE. The call stamps
                updated_by_key_id on the row (never a fabricated staff identity, since
                an api key is not one) and appends the prior state to an append-only
                history before returning, so a merchant can always see who changed
                what and when, and reconstruct what the text said before this call.
                DELETE IS NOT PUBLISHED. Removing a policy takes the terms off the
                storefront while checkout keeps completing, and there is no dedicated
                restore route even though the history above could inform a manual fix
                from the dashboard.
              properties:
                title:
                  type: string
                  description: The merchant's own heading for the document.
                body:
                  type: string
                  description: >-
                    The policy text, stored verbatim. Not HTML-escaped and not
                    stripped of markup; whatever renders it owns its own output
                    safety. Omitted or empty both store an empty document.
                status:
                  type: string
                  enum: [draft, published]
                  description: >-
                    published means shoppers can read it on the storefront
                    immediately. Case-insensitive on the way in, served lower-case.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body:
                        type: string
                      created_at:
                        type: string
                      policy_type:
                        type: string
                      status:
                        type: string
                      title:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_store_policies scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/storefront-preview-sessions:
    post:
      summary: Mint a preview link for the store's unpublished storefront
      operationId: post_storefront_preview_sessions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      expires_at:
                        type: string
                      url:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/storefront-templates:
    get:
      summary: List storefront templates
      description: >-
        Every page template this store has authored, draft and published markup side
        by side, so "is anything unpublished" is one call rather than two and a race
        between them.
        A TEMPLATE IS ADDRESSED BY NAME, and the names are a closed set: header, home,
        collection, product, search, cart, 404, footer. There is no id. A kind this
        store has never authored is simply ABSENT from this list rather than present
        and empty, because a placeholder would have no updated_at and therefore no
        version, which is a row you could read and could not write.
        published_markup is what shoppers are actually served. Empty means they get the
        platform's own built-in page for that kind, which is the normal state and not a
        fault.
        chrome true means the template renders on EVERY route of the storefront rather
        than on one page, which is the header and the footer today. It is held to a
        stricter markup gate, and a mistake in one is a mistake on every URL of the
        store.
      operationId: get_storefront_templates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        chrome:
                          type: boolean
                        created_at:
                          type: string
                        draft_markup:
                          type: string
                        name:
                          type: string
                        published:
                          type: boolean
                        published_markup:
                          type: string
                        updated_at:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/storefront-templates/{templateName}:
    get:
      summary: Retrieve a storefront template
      description: >-
        One template, with the updated_at both writes require you to send back.
        A name outside the closed set is 404 rather than 400, the same answer as a name
        this store has simply not authored yet: there is nothing at that address either
        way, and two statuses for one fact would make every client branch. Create one
        with PUT and expected_updated_at null.
      operationId: get_storefront_templates_by_templateName
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      parameters:
        - name: templateName
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chrome:
                        type: boolean
                      created_at:
                        type: string
                      draft_markup:
                        type: string
                      name:
                        type: string
                      published:
                        type: boolean
                      published_markup:
                        type: string
                      updated_at:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Replace a storefront template's draft markup
      description: >-
        Replaces the DRAFT markup. Shoppers keep seeing the published copy until you
        publish, so this is safe to run against a live store all day.
        THE WHOLE DOCUMENT REPLACES THE STORED DRAFT. There is no merge and no partial
        update: markup is a document, and merging two of them field by field produces
        one neither writer intended.
        expected_updated_at IS REQUIRED, and this is the field the family is built
        around. A template carries no record of who wrote it: the row is keyed
        (store_id, name) with no created_by and no owner_app_id, so if you and another
        integration both author "product", nothing in the data can tell your document
        from theirs. The version is the only thing that can. Send null only when you
        believe the store has no template of this name at all; otherwise send the
        updated_at you last read. A mismatch is 409 template_modified, and the fix is
        always the same: re-read, re-apply your change, re-send.
        The markup is validated on the way in, against a stricter gate for chrome kinds
        than for pages, and a refusal names the construct it refused.
      operationId: put_storefront_templates_by_templateName
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: templateName
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [draft_markup, expected_updated_at]
              additionalProperties: false
              description: >-
                BOTH KEYS ARE REQUIRED and neither is defaulted. An omitted draft_markup
                would mean an empty template rather than an unchanged one, and defaulting
                expected_updated_at would make the unsafe write the easy one.
                published_markup, published, name, store_id, chrome and updated_at are
                refused BY NAME rather than ignored, so a client that tried to change the
                live storefront directly is told instead of being answered 200 while
                nothing happened.
              properties:
                draft_markup:
                  type: string
                  description: >-
                    The whole template, which REPLACES the stored draft. It is validated
                    on the way in against the same gate that guards the rendered page, and
                    against a stricter one for chrome kinds; a refusal is 400
                    invalid_markup naming the construct. Nothing here reaches a shopper
                    until you publish.
                expected_updated_at:
                  type: [string, "null"]
                  description: >-
                    The updated_at you last read. NULL asserts that this store has no
                    template of this name, which is the only way a first write can name a
                    version that does not exist; it is refused the moment a row does
                    exist, so it cannot be used to skip the check. A mismatch is 409
                    template_modified. Nothing on this resource records an author, so this
                    is the only thing that can tell two writers of one name apart.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chrome:
                        type: boolean
                      created_at:
                        type: string
                      draft_markup:
                        type: string
                      name:
                        type: string
                      published:
                        type: boolean
                      published_markup:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/storefront-templates/{templateName}/publish:
    post:
      summary: Publish a storefront template's draft to shoppers
      description: >-
        Makes the STORED draft the markup every shopper renders for this page kind.
        It publishes what the server already holds, never markup in this request:
        save it with PUT /api/v1/storefront-templates/{templateName} first, then
        publish the updated_at that write returned. Sending markup here is a 400.
        THE PRECONDITION MATTERS MORE HERE THAN ON THE WRITE. A stale draft save loses
        one edit; a stale publish makes markup you never saw the page every shopper
        gets, and answers 200 as though it were yours. null is not accepted: it would
        assert the template does not exist, and there is nothing to publish when that
        is true.
        AN EMPTY DRAFT IS REFUSED with 409 nothing_to_publish. Promoting it would take
        the page off the storefront rather than update it, and this API offers no route
        that does that.
        RETRIES: replaying the same Idempotency-Key returns the recorded response and
        publishes nothing. A FRESH key after a publish that already succeeded is 409
        template_modified, because that publish moved updated_at.
      operationId: post_storefront_templates_by_templateName_publish
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_storefront
      x-required-scopes: [write_storefront]
      parameters:
        - name: templateName
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [expected_updated_at]
              additionalProperties: false
              description: >-
                The only input is the version you expect. draft_markup sent here is
                refused by name: this route promotes the document the server already
                validated, and accepting one in the body would put markup that never
                passed the gate in front of every shopper.
              properties:
                expected_updated_at:
                  type: string
                  description: >-
                    The updated_at you last read, and NOT nullable here: null asserts the
                    template does not exist, and there is nothing to publish when that is
                    true. A mismatch is 409 template_modified and means the draft moved
                    under you, so what you would have published is somebody else's markup.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chrome:
                        type: boolean
                      created_at:
                        type: string
                      draft_markup:
                        type: string
                      name:
                        type: string
                      published:
                        type: boolean
                      published_markup:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/storefront-theme-translations:
    post:
      summary: Translate a Shopify theme's configuration into a builder config
      operationId: post_storefront_theme_translations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_storefront
      x-required-scopes: [read_storefront]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [index]
              properties:
                settings_data:
                  type: string
                  description: config/settings_data.json, the theme's global settings.
                index:
                  type: string
                  description: templates/index.json, the homepage layout. Required.
                product:
                  type: string
                  description: templates/product.json. Read only to report what it contains.
                collection:
                  type: string
                  description: templates/collection.json. Read only to report what it contains.
                header_group:
                  type: string
                  description: sections/header-group.json. The header and announcement bar live here, NOT in index.json; omitting it imports a store with no header.
                footer_group:
                  type: string
                  description: sections/footer-group.json. The footer lives here, NOT in index.json.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      global:
                        type: object
                        properties:
                          accent_color:
                            type: string
                          button_color:
                            type: string
                          font_family:
                            type: string
                          footer_background:
                            type: string
                          nav_background:
                            type: string
                          primary_color:
                            type: string
                          product_card_variant:
                            type: string
                          secondary_color:
                            type: string
                      sectionOrder:
                        type: array
                        items:
                          type: string
                      sections:
                        type: object
                        properties:
                          announcement_bar:
                            type: object
                            properties:
                              text:
                                type: string
                          best_sellers:
                            type: object
                            properties:
                              title:
                                type: string
                          footer:
                            type: object
                            properties:
                              newsletter_enable:
                                type: boolean
                          header:
                            type: object
                            properties:
                              logo_width:
                                type: number
                          hero_slider:
                            type: object
                            properties:
                              heading:
                                type: string
                      unmapped:
                        type: array
                        items:
                          type: object
                          properties:
                            reason:
                              type: string
                            shopify_type:
                              type: string
                            source:
                              type: string
                      version:
                        type: number
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_storefront scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions:
    get:
      summary: List subscriptions
      operationId: get_subscriptions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_subscriptions
      x-required-scopes: [read_subscriptions]
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this customer_id. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this status. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        cadence:
                          type: string
                        cancelled_at:
                          nullable: true
                        created_at:
                          type: string
                        currency_code:
                          nullable: true
                        customer_id:
                          type: string
                        id:
                          type: string
                        next_run_at:
                          type: string
                        quantity:
                          type: integer
                        status:
                          type: string
                        unit_price_minor:
                          type: [integer, "null"]
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions/{subscriptionId}:
    get:
      summary: Retrieve a subscription
      operationId: get_subscriptions_by_subscriptionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_subscriptions
      x-required-scopes: [read_subscriptions]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cadence:
                        type: string
                      cancelled_at:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      quantity:
                        type: integer
                      status:
                        type: string
                      unit_price_minor:
                        type: integer
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions/{subscriptionId}/cancel:
    post:
      summary: Cancel a subscription
      operationId: post_subscriptions_by_subscriptionId_cancel
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_subscriptions
      x-required-scopes: [write_subscriptions]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. TERMINAL AND IRREVERSIBLE: there is no route that
                returns a cancelled subscription to active, and the shopper must
                subscribe again. Accepted from active or paused; a subscription already
                cancelled is 409 invalid_transition rather than a silent success, so a
                retry cannot read as a fresh cancellation.

                The row is kept, not deleted. Its price, cadence and run ledger stay
                where churn analysis and a payment dispute both need to find them, which
                is why this family publishes no delete qualifier.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cadence:
                        type: string
                      cancelled_at:
                        type: string
                      created_at:
                        type: string
                      currency_code:
                        nullable: true
                      customer_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      quantity:
                        type: integer
                      status:
                        type: string
                      unit_price_minor:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions/{subscriptionId}/pause:
    post:
      summary: Pause a subscription
      operationId: post_subscriptions_by_subscriptionId_pause
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_subscriptions
      x-required-scopes: [write_subscriptions]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. Only an ACTIVE subscription can pause; any other
                status is refused with 409 invalid_transition rather than silently
                doing nothing. Cycles stop advancing until it is resumed, and
                next_run_at is re-anchored at that point rather than kept, so a long
                pause does not fire a backlog of missed cycles on resume.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cadence:
                        type: string
                      cancelled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        nullable: true
                      customer_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      quantity:
                        type: integer
                      status:
                        type: string
                      unit_price_minor:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions/{subscriptionId}/resume:
    post:
      summary: Resume a paused subscription
      operationId: post_subscriptions_by_subscriptionId_resume
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_subscriptions
      x-required-scopes: [write_subscriptions]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. Only a PAUSED subscription can resume; any other
                status is refused with 409 invalid_transition. The next cycle is
                anchored one cadence from NOW rather than from where the pause began,
                so resuming never immediately charges for time spent paused.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cadence:
                        type: string
                      cancelled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        nullable: true
                      customer_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      quantity:
                        type: integer
                      status:
                        type: string
                      unit_price_minor:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/subscriptions/{subscriptionId}/skip:
    post:
      summary: Skip a subscription's next cycle
      operationId: post_subscriptions_by_subscriptionId_skip
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_subscriptions
      x-required-scopes: [write_subscriptions]
      parameters:
        - name: subscriptionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters. Advances next_run_at by one cadence WITHOUT
                placing an order, which is the difference between this and pause: the
                subscription stays active and the cycle after next is unaffected. Only
                an ACTIVE subscription can skip; any other status is 409
                invalid_transition. Skipping twice skips two cycles.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cadence:
                        type: string
                      cancelled_at:
                        nullable: true
                      created_at:
                        type: string
                      currency_code:
                        nullable: true
                      customer_id:
                        type: string
                      id:
                        type: string
                      next_run_at:
                        type: string
                      quantity:
                        type: integer
                      status:
                        type: string
                      unit_price_minor:
                        type: [integer, "null"]
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_subscriptions scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tag-assignments/attach:
    post:
      summary: Attach tags to products
      operationId: post_tag_assignments_attach
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags
      x-required-scopes: [write_tags]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_ids, tag_values]
              additionalProperties: false
              description: >-
                A CROSS PRODUCT: every listed tag is applied to every listed product.
                Attaching a tag a product already carries is not an error. The response
                is each product's resulting tag list, so a replay returns the same body.
              properties:
                product_ids:
                  type: array
                  items: { type: string }
                tag_values:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      products:
                        type: array
                        items:
                          type: object
                          properties:
                            product_id:
                              type: string
                            tag_values:
                              type: array
                              items:
                                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tag-assignments/detach:
    post:
      summary: Detach tags from products
      operationId: post_tag_assignments_detach
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags
      x-required-scopes: [write_tags]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_ids, tag_values]
              additionalProperties: false
              description: >-
                Also a cross product. Detaching a tag a product does not carry is not an
                error, so the response is the surviving tag list rather than a report of
                what was removed.
              properties:
                product_ids:
                  type: array
                  items: { type: string }
                tag_values:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      products:
                        type: array
                        items:
                          type: object
                          properties:
                            product_id:
                              type: string
                            tag_values:
                              type: array
                              items:
                                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tags:
    get:
      summary: List tags
      operationId: get_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tags
      x-required-scopes: [read_tags]
      parameters:
        - name: product_id
          in: query
          required: false
          schema: { type: string }
          description: The tags assigned to one product.
        - name: value
          in: query
          required: false
          schema: { type: string }
          description: An exact tag value, which is unique per store.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Tags touched at or after this RFC3339 instant. A tag's value is merchandising text
            your storefront renders, so missing a rename shows shoppers a label the merchant
            retired. Supplying it CHANGES THE ORDER of this endpoint: the default walk
            descends created_at, this one climbs updated_at, which is the only ordering in
            which a keyset walk over a mutable column cannot skip a row. A tag edited mid-walk
            is handed to you again, so the walk is AT LEAST ONCE. THE ONE SURPRISE: ?product_id=
            matches through the product-to-tag join, which carries no timestamp of its own, so
            ASSIGNING a tag to a product does not move that tag in this walk. Only editing the
            tag itself does; reconcile assignments from the product side. The bound is
            INCLUSIVE, so resuming from the newest updated_at you hold re-delivers the rows
            sharing that instant. A cursor belongs to ONE ordering and is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        updated_at:
                          type: string
                        value:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a tag
      operationId: post_tags
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags
      x-required-scopes: [write_tags]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              additionalProperties: false
              properties:
                value:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tags/{tagId}:
    delete:
      summary: Delete a tag
      operationId: delete_tags_by_tagId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags:delete
      x-required-scopes: [write_tags, write_tags:delete]
      parameters:
        - name: tagId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tags and then on write_tags:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tags, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a tag
      operationId: get_tags_by_tagId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tags
      x-required-scopes: [read_tags]
      parameters:
        - name: tagId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Rename a tag
      operationId: patch_tags_by_tagId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags
      x-required-scopes: [write_tags]
      parameters:
        - name: tagId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              additionalProperties: false
              properties:
                value:
                  type: string
                  description: >-
                    Renames the tag everywhere it is applied; it does not create a new
                    one. Every product carrying it shows the new value.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tags scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tags/{tagId}/restore:
    post:
      summary: Restore a deleted tag
      operationId: post_tags_by_tagId_restore
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tags:delete
      x-required-scopes: [write_tags, write_tags:delete]
      parameters:
        - name: tagId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. Brings back a tag
                removed by DELETE, together with every product it was applied to:
                deleting a tag hides it without dropping its product links, so this is
                a true undo rather than a re-creation.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tags and then on write_tags:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tags, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-classes:
    get:
      summary: List tax classes
      operationId: get_tax_classes
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: [string, "null"]
                        created_at:
                          type: string
                        id:
                          type: string
                        name:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a product tax class
      operationId: post_tax_classes
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, code]
              additionalProperties: false
              description: >-
                code is UNIQUE PER STORE, so a duplicate is a 409 rather than a second
                class. That is the convergence guarantee tax rates cannot offer: a create
                retried outside the idempotency window collides instead of minting a twin.
              properties:
                name:
                  type: string
                  description: Required, trimmed, and must not be blank.
                code:
                  type: string
                  description: >-
                    Required and unique per store. Published as stored: not upper-cased,
                    not normalised, and not a code from any tax standard. Address a class
                    by its id rather than by this, since a PATCH rewrites it.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-classes/{taxClassId}:
    delete:
      summary: Delete a product tax class
      operationId: delete_tax_classes_by_taxClassId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax:delete
      x-required-scopes: [write_tax, write_tax:delete]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tax and then on write_tax:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tax, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a tax class
      operationId: get_tax_classes_by_taxClassId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a product tax class
      operationId: patch_tax_classes_by_taxClassId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value. Neither field can be cleared: both
                columns are NOT NULL, so a present key that trims to empty is refused.
              properties:
                name:
                  type: string
                  description: Must not be blank.
                code:
                  type: string
                  description: >-
                    Must not be blank, and stays unique per store: a collision is a 409.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      name:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-classes/{taxClassId}/rates:
    get:
      summary: List a tax class's rates
      operationId: get_tax_classes_by_taxClassId_rates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        rate_bps:
                          type: integer
                        region_id:
                          type: string
                        tax_class_id:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-classes/{taxClassId}/rates/{regionId}:
    delete:
      summary: Remove a tax class rate for one region
      operationId: delete_tax_classes_by_taxClassId_rates_by_regionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax:delete
      x-required-scopes: [write_tax, write_tax:delete]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
        - name: regionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tax and then on write_tax:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tax, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a tax class rate for one region
      operationId: put_tax_classes_by_taxClassId_rates_by_regionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: taxClassId
          in: path
          required: true
          schema: { type: string }
        - name: regionId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rate_bps]
              additionalProperties: false
              description: >-
                An UPSERT addressed by the path pair, so it is idempotent IN EFFECT and
                not merely by Idempotency-Key: a reconciler retried outside the window
                converges on the same row. {regionId} names a MARKET region, from
                /api/v1/regions under read_regions, and NOT a tax region: the two are
                different tables with different id spaces. This rate is what a classed
                product is actually charged, since it is preferred over the store default.
              properties:
                rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: >-
                    Required. An integer count of BASIS POINTS: 1800 means 18.00%. It is
                    not defaulted, because a missing rate would silently zero-rate this
                    class in this market.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      rate_bps:
                        type: integer
                      region_id:
                        type: string
                      tax_class_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rate-rules:
    get:
      summary: List tax rate rules
      operationId: get_tax_rate_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: reference
          in: query
          required: false
          schema: { type: string, enum: [product, product_type, product_tax_class] }
          description: >-
            What kind of target to filter on. Must be sent together with reference_id;
            either half alone is a 400. A value outside this set is a 400 rather than an
            empty page, because an empty page means "this target carries no override",
            which is the ordinary state of most products and a different answer entirely.
        - name: reference_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            The id of the target, read according to reference. Must be sent together with
            reference. Omit both to receive every rule in the store, which is the shape a
            migration or an ERP needs: the rule is the layer that overrides a class rate,
            so a client that skipped it has reconstructed a tax matrix the store does not
            charge.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Rules touched at or after this RFC3339 instant, for reconciling incrementally instead
            of re-reading the whole rule set on every run. It composes with reference and
            reference_id: narrowing and ordering are independent, and all four combinations are
            served. Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, because that is the only ordering in which
            a keyset walk over a mutable column cannot skip a row. A rule changed mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE. WHAT A
            MISS COSTS HERE IS A TAX NUMBER: a rule is the layer that OVERRIDES a class rate, so a
            client that missed one has reconstructed a tax matrix the store does not charge. A
            cursor from one ordering is refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        reference:
                          type: string
                        reference_id:
                          type: string
                        tax_rate_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rate-rules/{taxRateRuleId}:
    delete:
      summary: Remove a tax rate rule
      operationId: delete_tax_rate_rules_by_taxRateRuleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax:delete
      x-required-scopes: [write_tax, write_tax:delete]
      parameters:
        - name: taxRateRuleId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tax and then on write_tax:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tax, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a tax rate rule
      operationId: get_tax_rate_rules_by_taxRateRuleId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxRateRuleId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      reference:
                        type: string
                      reference_id:
                        type: string
                      tax_rate_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rates:
    get:
      summary: List tax rates
      operationId: get_tax_rates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Rates touched at or after this RFC3339 instant. A TAX RATE DECIDES WHAT A SHOPPER
            IS CHARGED, and rates change by legislation rather than merchant whim, so a cached
            copy keeps charging the old percentage. created_at is written once; the percentage
            is what moves.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: [string, "null"]
                        created_at:
                          type: string
                        id:
                          type: string
                        is_combinable:
                          type: boolean
                        is_default:
                          type: boolean
                        name:
                          type: string
                        rate_bps:
                          type: integer
                        tax_region_id:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Create a tax rate
      operationId: post_tax_rates
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, rate_bps]
              additionalProperties: false
              description: >-
                THERE IS NO CONVERGENCE GUARANTEE OUTSIDE THE IDEMPOTENCY WINDOW on this
                route. tax_rate has no unique column, so a create retried after the window
                mints a SECOND rate rather than colliding. Reconcile by listing, not by
                re-posting. The jurisdiction columns (tax_region_id, code, is_combinable)
                are refused here by name: they belong to
                PUT /api/v1/tax-rates/{taxRateId}/jurisdiction.
              properties:
                name:
                  type: string
                  description: >-
                    Required and must not be blank. Not unique: a merchant may run two
                    rates called "GST" in two jurisdictions.
                rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: >-
                    Required. An integer count of BASIS POINTS: 1800 means 18.00%. Send
                    percent * 100, or fraction * 10000. It is not defaulted, because a
                    missing rate would silently create a zero-rated tax.
                is_default:
                  type: boolean
                  description: >-
                    Optional, defaults to false. Setting it true CLEARS the store's
                    existing default in the same transaction, because at most one default
                    is allowed per store, so it changes what an unclassified line is
                    charged at on a rate this call never named.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      id:
                        type: string
                      is_combinable:
                        type: boolean
                      is_default:
                        type: boolean
                      name:
                        type: string
                      rate_bps:
                        type: integer
                      tax_region_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rates/{taxRateId}:
    delete:
      summary: Delete a tax rate
      operationId: delete_tax_rates_by_taxRateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax:delete
      x-required-scopes: [write_tax, write_tax:delete]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_tax and then on write_tax:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_tax, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a tax rate
      operationId: get_tax_rates_by_taxRateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: [string, "null"]
                      created_at:
                        type: string
                      id:
                        type: string
                      is_combinable:
                        type: boolean
                      is_default:
                        type: boolean
                      name:
                        type: string
                      rate_bps:
                        type: integer
                      tax_region_id:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a tax rate
      operationId: patch_tax_rates_by_taxRateId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                An omitted key keeps its stored value. A PUT would make forgetting
                is_default silently unset the store's default rate, which is why this is a
                PATCH. The jurisdiction columns are refused by name and belong to
                PUT /api/v1/tax-rates/{taxRateId}/jurisdiction.
              properties:
                name:
                  type: string
                  description: Must not be blank. Omit the key to leave it unchanged.
                rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: An integer count of BASIS POINTS (1800 = 18.00%).
                is_default:
                  type: boolean
                  description: >-
                    Setting it true clears the store's existing default in the same
                    transaction.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_combinable:
                        type: boolean
                      is_default:
                        type: boolean
                      name:
                        type: string
                      rate_bps:
                        type: integer
                      tax_region_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rates/{taxRateId}/jurisdiction:
    put:
      summary: Set the jurisdiction a tax rate applies in
      operationId: put_tax_rates_by_taxRateId_jurisdiction
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tax_region_id, code, is_combinable]
              additionalProperties: false
              description: >-
                A PUT with every key required, because the three fields are ONE setting
                rather than three: a partial write leaves a rate bound to a jurisdiction
                the caller no longer meant, and the resolver keeps applying it there. The
                response is the WHOLE rate, identical to
                GET /api/v1/tax-rates/{taxRateId}.
              properties:
                tax_region_id:
                  type: [string, "null"]
                  description: >-
                    A tax region in THIS store, from /api/v1/tax-regions. Null clears the
                    link and widens the rate from one jurisdiction to the whole store. An
                    id from another store is refused: the foreign key alone would not
                    catch it.
                code:
                  type: [string, "null"]
                  description: >-
                    An optional short merchant label ("GST", "VAT-RED"), or null to clear
                    it. Opaque text with no schema and no enum: it is not a code from any
                    tax standard.
                is_combinable:
                  type: boolean
                  description: >-
                    Required. True means this rate STACKS additively on other matching
                    rates; false means it OVERRIDES them. It is not defaulted, because
                    defaulting it would change what the merchant charges on a key the
                    caller never sent.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      is_combinable:
                        type: boolean
                      is_default:
                        type: boolean
                      name:
                        type: string
                      rate_bps:
                        type: integer
                      tax_region_id:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-rates/{taxRateId}/rules:
    get:
      summary: List a tax rate's rules
      operationId: get_tax_rates_by_taxRateId_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            This rate's rules touched at or after this RFC3339 instant, for reconciling
            incrementally instead of re-reading them on every run. Supplying it CHANGES THE ORDER
            of this endpoint: the default walk descends created_at, and this one climbs updated_at,
            because that is the only ordering in which a keyset walk over a mutable column cannot
            skip a row. A rule changed mid-walk moves forward past your position and is returned
            again, so the walk is AT LEAST ONCE. A cursor from one ordering is refused by the
            other, since the same position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        id:
                          type: string
                        reference:
                          type: string
                        reference_id:
                          type: string
                        tax_rate_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Bind a tax rate to a product, product type or tax class
      operationId: post_tax_rates_by_taxRateId_rules
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_tax
      x-required-scopes: [write_tax]
      parameters:
        - name: taxRateId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reference, reference_id]
              additionalProperties: false
              description: >-
                THIS IS THE LAYER THAT DECIDES WHAT A PRODUCT ACTUALLY PAYS. The resolver
                ranks the rules matching a line by how specific their binding is, product
                beating product_type beating product_tax_class beating the jurisdiction
                default, and takes the most specific non-combinable one as the base rate.
                IT IS IDEMPOTENT IN EFFECT, not merely by Idempotency-Key: the binding is
                unique per (rate, reference, reference_id), so a create replayed outside
                the idempotency window returns the rule that is already there instead of
                minting a second. That is what makes a reconciler binding one rate across
                a whole catalogue safe to re-run, and it is a guarantee
                POST /api/v1/tax-rates cannot offer.
                THERE IS NO PATCH. A rule is a binding rather than a record with editable
                fields, so a rebind is a delete plus a create, and the delete needs
                write_tax:delete.
              properties:
                reference:
                  type: string
                  enum: [product, product_type, product_tax_class]
                  description: >-
                    Required. What KIND of thing reference_id names. The three are a
                    PRECEDENCE order, most specific first, not interchangeable tags:
                    sending the wrong one changes which rate wins on every line where two
                    of them match. A value outside the set is a 400.
                reference_id:
                  type: string
                  description: >-
                    Required. The id of the target, read according to reference. IT IS NOT
                    VALIDATED against the catalogue and carries no foreign key, so a rule
                    may name a product that was deleted afterwards. Such a rule is INERT
                    rather than broken: it never becomes a candidate and the line falls
                    back to the next most specific rate. That is deliberate, so a
                    migration can write its tax matrix before its products.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      id:
                        type: string
                      reference:
                        type: string
                      reference_id:
                        type: string
                      tax_rate_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-regions:
    get:
      summary: List tax regions
      operationId: get_tax_regions
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Regions touched at or after this RFC3339 instant, for reconciling incrementally
            instead of re-reading the jurisdiction tree on every run. Supplying it CHANGES THE
            ORDER of this endpoint: the default walk descends created_at, and this one climbs
            updated_at, because that is the only ordering in which a keyset walk over a mutable
            column cannot skip a row. A region changed mid-walk moves forward past your position
            and is returned again, so the walk is AT LEAST ONCE: apply the state of each row you
            receive and a repeat costs nothing. WHAT A MISS COSTS HERE IS A TAX NUMBER: the
            tax_region_id on a rate points into this list, so a client holding a stale region
            resolves a rate to the wrong jurisdiction. A cursor from one ordering is refused by
            the other, since the same position means something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        country_code:
                          type: string
                        created_at:
                          type: string
                        id:
                          type: string
                        parent_id:
                          type: [string, "null"]
                        province_code:
                          type: [string, "null"]
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/tax-regions/{taxRegionId}:
    get:
      summary: Retrieve a tax region
      operationId: get_tax_regions_by_taxRegionId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_tax
      x-required-scopes: [read_tax]
      parameters:
        - name: taxRegionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      country_code:
                        type: string
                      created_at:
                        type: string
                      id:
                        type: string
                      parent_id:
                        type: [string, "null"]
                      province_code:
                        type: [string, "null"]
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_tax scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/trade-credit-ledger:
    get:
      summary: List trade credit ledger
      description: >-
        The movements behind outstanding_minor: the append-only record of every
        charge against a buyer's credit and every settlement of it.
        THE SIGN IS THE MEANING. A positive amount_minor draws credit down, a
        negative one frees it, and the plain sum of the column IS the outstanding
        balance. Taking an absolute value turns a reconciliation into gross turnover
        reported as debt.
        reference_type and reference_id are the pair a movement is idempotent on, and
        they are OPAQUE STRINGS WITH NO DECLARED ENUM. Match on them, do not switch
        on them: the checkout flow that will be the main producer does not exist yet,
        so there is no vocabulary to freeze and none is being invented.
        ?customer_id narrows it to one buyer. Omit it to walk the whole store in one
        ordered pass, which is what an accounts-receivable reconciliation wants.
        APPEND ONLY AT THE DATABASE GRANT, not by convention: no code path in this
        platform can update or delete a movement, which is what makes this a
        tamper-evident trail rather than a cache of the balance.
      operationId: get_trade_credit_ledger
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Restrict the page to one buyer's movements. Omit it to walk the whole
            store's ledger newest first, which is what an accounts-receivable
            reconciliation wants and what the dashboard's own per-customer view
            cannot do.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: [string, "null"]
                        customer_id:
                          type: string
                        id:
                          type: string
                        reference_id:
                          type: string
                        reference_type:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/trade-credit-terms:
    get:
      summary: List trade credit terms
      description: >-
        Every B2B buyer this store has granted trade credit, with what they have been
        lent, what they currently owe and how long they have to pay.
        TRADE CREDIT IS THE OPPOSITE OF STORE CREDIT AND THE TWO MUST NEVER BE
        SUMMED. A store-credit balance is money the merchant OWES the shopper and the
        shopper can spend it at checkout. Trade credit is what the merchant has LENT
        the buyer, so outstanding is a debt owed TO the merchant. A client that adds
        them is wrong by twice the smaller figure.
        IT PUBLISHES A CUSTOMER ID AND THREE NUMBERS AND NOTHING ELSE ABOUT THE
        PERSON. No email, no name, no embedded customer object: read_offline_payments
        must not become a customer enumerator. Ask for read_customers as well and
        join on customer_id.
        READ ONLY, permanently. No route on this API sets a credit limit under any
        grant, because raising one lets a named buyer take goods without paying,
        which is money leaving the merchant with no charge and no approval behind it.
        The merchant sets terms in their dashboard.
      operationId: get_trade_credit_terms
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        available_minor:
                          type: integer
                        created_at:
                          type: string
                        credit_limit_minor:
                          type: integer
                        currency_code:
                          type: [string, "null"]
                        customer_id:
                          type: string
                        net_terms_days:
                          type: integer
                        outstanding_minor:
                          type: integer
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/trade-credit-terms/{customerId}:
    get:
      summary: Retrieve a trade credit term
      description: >-
        One buyer's trade-credit standing.
        404 MEANS NO TERMS EXIST, which is not the same as a limit of zero. Zero is a
        merchant decision (this buyer is on account with nothing extended yet); no
        terms at all means nobody decided anything.
        available_minor CAN BE NEGATIVE and is deliberately not clamped. The limit is
        checked when a charge posts, so lowering it afterwards claws nothing back and
        leaves the buyer overdrawn. Flooring the field at zero would hide a real debt
        in the one number you would read to find it.
      operationId: get_trade_credit_terms_by_customerId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_offline_payments
      x-required-scopes: [read_offline_payments]
      parameters:
        - name: customerId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available_minor:
                        type: integer
                      created_at:
                        type: string
                      credit_limit_minor:
                        type: integer
                      currency_code:
                        type: [string, "null"]
                      customer_id:
                        type: string
                      net_terms_days:
                        type: integer
                      outstanding_minor:
                        type: integer
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_offline_payments scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/translations:
    get:
      summary: List translations
      operationId: get_translations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_translations
      x-required-scopes: [read_translations]
      parameters:
        - name: resource_type
          in: query
          required: true
          schema: { type: string, enum: [product, collection, page, category] }
          description: >-
            Required. Together with resource_id it bounds the read to one entity,
            which is why this list carries no cursor: a single resource's translated
            fields are a small, complete set. A value outside the enum is a 400, not
            an empty page, because "no translations" and "no such resource type" are
            different facts and a client told the former stops looking.
        - name: resource_id
          in: query
          required: true
          schema: { type: string }
          description: >-
            Required. The id of the product, collection, page or category whose
            translations to read.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        field:
                          type: string
                        id:
                          type: string
                        locale:
                          type: string
                        origin:
                          type: string
                        resource_id:
                          type: string
                        resource_type:
                          type: string
                        source:
                          type: string
                        updated_at:
                          type: string
                        value:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_translations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a translation for one field in one locale
      operationId: put_translations
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_translations
      x-required-scopes: [write_translations]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource_type, resource_id, field, locale, value, source]
              additionalProperties: false
              description: >-
                An UPSERT on (resource_type, resource_id, field, locale), so replaying
                the same request converges rather than accumulating. The locale must
                already be enabled on the store; one that is not answers 409, and GET
                /api/v1/locales is the enabled set.
                Every write through this route is recorded with origin=manual, and
                that cannot be opted out of: the store's own auto-translate skips
                manual rows so it never clobbers a human, so a row written here is
                permanently exempt from retranslation. origin is published on the read
                so the effect is visible.
              properties:
                resource_type:
                  type: string
                  enum: [product, collection, category, page]
                resource_id:
                  type: string
                field:
                  type: string
                locale:
                  type: string
                  description: Must be enabled on the store. See GET /api/v1/locales.
                value:
                  type: string
                  description: >-
                    The translated text. May NOT be empty: the storefront applies
                    whatever is stored, so "" would render a blank to shoppers rather
                    than falling back to the source. Removing the translation is what
                    restores the source text, and that is the DELETE.
                source:
                  type: string
                  description: >-
                    The CURRENT source text this value translates, and it is required.
                    Staleness is decided by comparing the recorded source against the
                    live one, and a row with no recorded source can never be PROVEN
                    stale, so it would be served forever even after the merchant
                    rewrote the source.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      field:
                        type: string
                      id:
                        type: string
                      locale:
                        type: string
                      origin:
                        type: string
                      resource_id:
                        type: string
                      resource_type:
                        type: string
                      source:
                        type: string
                      updated_at:
                        type: string
                      value:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_translations scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/translations/{translationId}:
    delete:
      summary: Remove a translation
      operationId: delete_translations_by_translationId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_translations:delete
      x-required-scopes: [write_translations, write_translations:delete]
      parameters:
        - name: translationId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_translations and then on write_translations:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_translations, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/variant-price-tiers/{priceTierId}:
    delete:
      summary: Remove a quantity price break
      operationId: delete_variant_price_tiers_by_priceTierId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing:delete
      x-required-scopes: [write_pricing, write_pricing:delete]
      parameters:
        - name: priceTierId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The tier id in the path
                addresses the band on its own, which is why this route is TOP LEVEL rather
                than nested under the variant: a {variantId} segment would be one nothing
                verifies, so a request naming variant A and a tier belonging to variant B
                would delete B's tier and answer as though it had honoured the path. The body
                is still READ AND VALIDATED: amount_minor and amount are refused BY NAME
                because a delete carries no amount, and variant_id, currency_code and
                min_quantity are refused because the tier id already names all three.
                EVERY ORDER IN THE REMOVED BAND REVERTS to the variant's resolved single-unit
                price at the next cart recompute, which is a PRICE INCREASE for exactly the
                shoppers the band was written for. Resolution is never snapshotted, so carts
                already open re-price. Nothing is emitted: this family publishes no webhook
                topics, so no integrator is told.
                IT IS NOT UNDONE BY A REPLAY: the previous amount is gone, there is no soft
                delete and no restore, and rebuilding the band means knowing its floor, its
                top and its amount and posting it again. That is why this route takes
                write_pricing:delete on top of write_pricing while the POST that writes a band
                takes write_pricing alone.
                204 whether or not a row was removed. Reporting the miss as 404 would fail a
                retry after a lost 204 for a request that had SUCCEEDED, and would make this
                route an existence oracle for tier ids in stores the key cannot read.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pricing and then on write_pricing:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pricing, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/variants/{variantId}/price-tiers:
    get:
      summary: List a variant's price tiers
      operationId: get_variants_by_variantId_price_tiers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: currency
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrows the list to rows matching this currency. Omit it for every row.
            This route reads no query parameter beyond the ones listed here: an
            unrecognised one is a 400 rather than being ignored, and so is the same
            parameter sent twice.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        max_quantity:
                          type: number
                        min_quantity:
                          type: number
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Add or replace a quantity price break on a variant
      operationId: post_variants_by_variantId_price_tiers
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currency_code, min_quantity, amount_minor]
              additionalProperties: false
              description: >-
                WHAT A SHOPPER IS CHARGED, not what they are shown. A tier is a BAND
                [min_quantity, max_quantity] whose amount replaces the variant's single-unit
                price for a line inside it.
                AN UPSERT KEYED ON (variant, currency, min_quantity), which is why this is a
                POST to the collection and answers 200 rather than 201: the write cannot say
                whether the band was created or replaced, and there is no detail route to
                point a Location at. Re-sending the same floor with a new amount REPLACES
                that band rather than adding a second one, so replaying converges.
                IT TAKES EFFECT AT THE NEXT CART RECOMPUTE. There is no active flag, no
                schedule and no review step, resolution happens at READ time and is never
                snapshotted, so carts already open re-price. Nothing is emitted: this family
                publishes no webhook topics, so a client mirroring quantity breaks must poll.
                TWO WRITES THAT SUCCEED AND CHANGE NOTHING, both answered 200. A band priced
                ABOVE the resolved unit price is stored, is returned by the GET, and is never
                charged, because the cart takes the lower of the unit price and the band: a
                tier meant as a quantity surcharge is inert. A band in a currency this store
                does not sell in is stored and inert too, because the column's foreign key is
                to the platform currency reference rather than to the store's enabled set, so
                nothing here refuses it and no cart ever resolves in that currency.
                id, variant_id, created_by, created_at and updated_at are refused BY NAME
                rather than ignored: the server mints the id and owns the timestamps,
                variant_id is in the path, and created_by is the audit record of the calling
                credential, which a client must not be able to write. amount, price,
                price_minor and unit_amount are refused the same way, because the admin route
                takes {"amount": 999} and a client copying that body would leave amount_minor
                at its zero value and sell the band for nothing. quantity, min_qty and max_qty
                are refused by name, and so is compare_at_amount_minor: a band price is not a
                discount off a struck-through price.
                Removing a band is DELETE /api/v1/variant-price-tiers/{priceTierId}, which
                takes write_pricing:delete on top of write_pricing.
              properties:
                currency_code:
                  type: string
                  description: >-
                    Required. Three ASCII letters, stored and matched in LOWERCASE, so "USD"
                    is normalised to "usd" here. The cart matches currency_code by equality,
                    and a tier stored uppercase would be listed back to the merchant and never
                    applied to a shopper. It travels in the body rather than the path because
                    a tier's identity is (variant, currency, min_quantity), so the currency
                    alone addresses no single tier. A code that is not a currency this platform
                    knows is a 400, not a 409: no merchant action would make it succeed.
                min_quantity:
                  type: integer
                  minimum: 2
                  description: >-
                    Required. The band FLOOR, not the quantity being bought. It is not
                    defaulted to 1, because quantity 1 is always the base price, and anything
                    below 2 is a 400. Bands for one variant and currency may NOT overlap: a
                    body that would overlap an existing band is a 409 and writes nothing, so
                    widening a band means removing the neighbour first.
                max_quantity:
                  type: [integer, "null"]
                  description: >-
                    The band top, inclusive, and it must not be below min_quantity. Null is a
                    REAL VALUE rather than an omission: it declares the open-ended top band (N
                    or more). Absent means the same thing, because a band being written for
                    the first time has nothing stored to leave alone.
                amount_minor:
                  type: integer
                  minimum: 0
                  maximum: 4503599627370496
                  description: >-
                    Required. The UNIT price inside the band, in INTEGER MINOR UNITS of
                    currency_code (1299 is 12.99 in a two-decimal currency), not a discount
                    off anything and not a line total. Omitting it is refused rather than
                    defaulted to zero: zero passes the range check, and a band below the unit
                    price is exactly what the cart charges, so the variant would sell for
                    nothing above the quantity floor.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      max_quantity:
                        type: number
                      min_quantity:
                        type: number
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/variants/{variantId}/prices:
    get:
      summary: List a variant's prices
      operationId: get_variants_by_variantId_prices
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_pricing
      x-required-scopes: [read_pricing]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        amount_minor:
                          type: integer
                        compare_at_amount_minor:
                          type: integer
                        created_at:
                          type: string
                        currency_code:
                          type: string
                        id:
                          type: string
                        updated_at:
                          type: string
                        variant_id:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/variants/{variantId}/prices/{currencyCode}:
    delete:
      summary: Remove a variant's price in one currency
      operationId: delete_variants_by_variantId_prices_by_currencyCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing:delete
      x-required-scopes: [write_pricing, write_pricing:delete]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: currencyCode
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The price is addressed by
                its natural key in the path. The body is still READ AND VALIDATED, so a client
                that assumes this route takes a reason or a filter is told rather than having
                the field silently ignored: amount_minor, amount and compare_at_amount_minor
                are refused BY NAME because a delete carries no amount, and variant_id and
                currency_code are refused because both are already in the path.
                THE VARIANT DOES NOT BECOME UNPRICED, IT FALLS BACK, and the fallback is
                usually a different number applied with no further request. In the store's
                default currency it reverts to the variant's base price, the one read_products
                serves as price_minor. In any other currency it reverts to an FX conversion of
                that base price, or the variant becomes unpriceable and disappears from
                checkout in that currency entirely.
                IT IS NOT UNDONE BY A REPLAY: the previous amount is gone, this family emits
                no event anyone could replay, and there is no soft delete and no restore.
                Restoring means knowing the old amount and sending the PUT again. That is why
                this route takes write_pricing:delete on top of write_pricing while the PUT
                beside it takes write_pricing alone.
                204 whether or not a row was removed. Reporting the miss as 404 would fail a
                retry after a lost 204 for a request that had SUCCEEDED, and would make this
                route an existence oracle for prices in stores the key cannot read. "This
                variant has no explicit price in this currency" is the state the caller asked
                for either way.
              properties: {}
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_pricing and then on write_pricing:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_pricing, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    put:
      summary: Set a variant's price in one currency
      operationId: put_variants_by_variantId_prices_by_currencyCode
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_pricing
      x-required-scopes: [write_pricing]
      parameters:
        - name: variantId
          in: path
          required: true
          schema: { type: string }
        - name: currencyCode
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_minor]
              additionalProperties: false
              description: >-
                WHAT A SHOPPER IS CHARGED. An explicit price on a variant WINS over the
                variant's base price outright, with no active flag, no schedule and no review
                step, so this is the write that changes the number at checkout.
                A MEMBER PUT, with the currency as the second half of the address. The service
                lowercases it, so /prices/USD and /prices/usd name the same row, and the set
                and the DELETE beside it address a price identically. It answers 200 rather
                than 201 because the upsert cannot say whether the row was created or
                replaced, and there is no Location the request did not already name. Replaying
                the same body converges.
                OMITTING compare_at_amount_minor CLEARS IT, which is what PUT means and is
                worth stating because PATCH-shaped clients get it wrong: a body naming only
                amount_minor removes an existing struck-through price. There is deliberately
                no way to say "leave the compare-at alone" on this address.
                THE TRAP, and it is the opposite of the price-list one: a price list applies
                only when it is strictly BELOW the base price, while a variant price applies
                whether it is higher or lower. This is the surface where a price INCREASE
                actually takes effect. Resolution happens at READ time and is never
                snapshotted, so carts already open re-price. Nothing is emitted: this family
                publishes no webhook topics, so a client mirroring prices must poll.
                A currency this store does not sell in is a 409 rather than a 400: the code is
                well formed and the store's own configuration is what refuses it, so enabling
                the currency is the merchant action that makes the same request succeed. A
                path segment that is not three letters is a 400 naming the address, so an
                integrator is not sent hunting for a variant that is fine.
                id, variant_id, created_at and updated_at are refused BY NAME rather than
                ignored: the server mints the id and owns the timestamps, and variant_id is in
                the path. currency_code and currency are refused for the same reason, they are
                in the path. amount, price and price_minor are refused because the admin route
                takes {"amount": 1299} and a client copying that body would leave amount_minor
                at its zero value, which prices the variant free. compare_at_amount,
                compare_at and on_sale are refused by name, on_sale because it is derived from
                the two amounts and is not stored. min_quantity and max_quantity are refused
                too: quantity breaks are POST /api/v1/variants/{variantId}/price-tiers.
              properties:
                amount_minor:
                  type: integer
                  minimum: 0
                  maximum: 1000000000000
                  description: >-
                    Required, in INTEGER MINOR UNITS of the currency in the path (1299 is
                    12.99 in a two-decimal currency). It may not be null and it is not
                    defaulted: an explicit price of ZERO still beats the base price, so a
                    missing amount read as 0 would make the variant free in this currency and
                    a zero-total order takes the free checkout path with no provider call.
                compare_at_amount_minor:
                  type: [integer, "null"]
                  description: >-
                    The struck-through "was" price, in the same currency and the same minor
                    units. It must be STRICTLY GREATER than amount_minor and no larger than
                    1000000000000, or the write is a 400 and nothing is stored. Explicit null
                    clears it, and so does omitting it. There is no on_sale field to send or
                    read: it would be true exactly when this one is non-null.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      amount_minor:
                        type: integer
                      compare_at_amount_minor:
                        type: integer
                      created_at:
                        type: string
                      currency_code:
                        type: string
                      id:
                        type: string
                      updated_at:
                        type: string
                      variant_id:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_pricing scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-deliveries:
    get:
      summary: List webhook deliveries
      description: >-
        The delivery log: one row per attempt, newest first.
        METADATA ONLY, deliberately. The event body is not published here, because
        that body is another family's data and read_webhooks is not a grant over it;
        a key holding only read_webhooks could otherwise read every order and
        customer that ever crossed a webhook. To recover a body you missed, replay
        the delivery and receive it, signed, at the endpoint you already registered.
      operationId: get_webhook_deliveries
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_webhooks
      x-required-scopes: [read_webhooks]
      parameters:
        - name: endpoint_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            One endpoint's deliveries. Omit it for every endpoint's.
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: >-
            Deliveries in one state. Omit it for every state.
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Deliveries touched at or after this RFC3339 instant. THIS IS THE WALK TO USE ON THIS
            LIST, and not because it is faster. A delivery row is created once and then only ever
            updated: every retry rewrites status, attempts, last_status_code, last_error and
            next_retry_at. The outcome you want is therefore almost always a LATER state of a row
            the default created_at walk has already handed you and will never hand you again.
            Supplying it CHANGES THE ORDER of this endpoint rather than filtering it: the default
            walk descends created_at, this one climbs updated_at, so a retry that lands mid-walk
            moves the row forward past your position and is returned again. The walk is AT LEAST
            ONCE, which is what makes the final state reachable at all: apply the state of each row
            you receive and a repeat costs nothing. It composes with endpoint_id and status. A
            cursor from one ordering is refused by the other, since the same position means
            something different in each.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        attempts:
                          type: integer
                        created_at:
                          type: string
                        endpoint_id:
                          type: string
                        event_type:
                          type: string
                        id:
                          type: string
                        last_error:
                          type: [string, "null"]
                        last_status_code:
                          type: [integer, "null"]
                        next_retry_at:
                          type: string
                        status:
                          type: string
                        updated_at:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-deliveries/{deliveryId}:
    get:
      summary: Retrieve a webhook delivery
      description: >-
        One delivery attempt. Carries no event body, for the reason given on the
        list; replay it to have the body sent to your endpoint.
      operationId: get_webhook_deliveries_by_deliveryId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_webhooks
      x-required-scopes: [read_webhooks]
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      created_at:
                        type: string
                      endpoint_id:
                        type: string
                      event_type:
                        type: string
                      id:
                        type: string
                      last_error:
                        type: [string, "null"]
                      last_status_code:
                        type: [integer, "null"]
                      next_retry_at:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-deliveries/{deliveryId}/payload:
    get:
      summary: Retrieve webhook delivery payload
      operationId: get_webhook_deliveries_by_deliveryId_payload
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_webhooks
      x-required-scopes: [read_webhooks]
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      event_type:
                        type: string
                      id:
                        type: string
                      payload:
                        type: object
                        properties:
                          id:
                            type: string
                          total_minor:
                            type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-deliveries/{deliveryId}/redeliver:
    post:
      summary: Replay a webhook delivery
      operationId: post_webhook_deliveries_by_deliveryId_redeliver
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks
      x-required-scopes: [write_webhooks]
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                201 because this CREATES a delivery. The original row is untouched, so
                what failed stays on the record, and the response is the NEW delivery
                whose id you watch.
                THIS IS HOW YOU GET A PAYLOAD YOU MISSED. The delivery log publishes
                metadata only, never the event body, because that body is the other
                family's data and read_webhooks is not a grant over it. Replaying
                sends the body to the endpoint you already registered, signed, where
                you can verify it.
                409 not_redeliverable when the source delivery is still pending
                (it is already on its way) or its endpoint is inactive (the attempt
                would just fail again).
              properties: {}
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      attempts:
                        type: integer
                      created_at:
                        type: string
                      endpoint_id:
                        type: string
                      event_type:
                        type: string
                      id:
                        type: string
                      last_error:
                        nullable: true
                      last_status_code:
                        type: [integer, "null"]
                      next_retry_at:
                        type: string
                      status:
                        type: string
                      updated_at:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-endpoints:
    get:
      summary: List webhook endpoints
      description: >-
        The endpoints registered for this store.
        NO SIGNING SECRET IS RETURNED, here or on any other read. A secret is
        readable exactly twice: in the response to the create that minted it, and in
        the response to a rotate. If you no longer hold one, rotate it rather than
        re-registering the endpoint, which would change its id.
      operationId: get_webhook_endpoints
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_webhooks
      x-required-scopes: [read_webhooks]
      parameters:
        - name: updated_after
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: >-
            Endpoints touched at or after this RFC3339 instant. An endpoint's url, its event
            subscription and whether it is active are all rewritten after creation, and this
            is the resource you are most likely to be reconciling programmatically in the
            first place; the default ordering is frozen the moment it is registered.
            Supplying it CHANGES THE ORDER of this endpoint: the default walk descends
            created_at, and this one climbs updated_at, which is the only ordering in which a
            keyset walk over a mutable column cannot skip a row. A row edited mid-walk moves
            forward past your position and is returned again, so the walk is AT LEAST ONCE.
            The bound is INCLUSIVE, so resuming from the newest updated_at you hold
            re-delivers the rows sharing that instant. A cursor belongs to ONE ordering and is
            refused by the other.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        auto_disabled_at:
                          nullable: true
                        created_at:
                          type: string
                        id:
                          type: string
                        is_active:
                          type: boolean
                        subscribed_events:
                          type: array
                          items:
                            type: string
                        updated_at:
                          type: string
                        url:
                          type: string
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    post:
      summary: Register a webhook endpoint
      description: >-
        Registers an endpoint and returns its signing secret.
        TWO GRANTS ARE CHECKED, not one. write_webhooks reaches this route; every
        entry in subscribed_events is then checked against its own family's read
        scope, so an endpoint receiving orders/paid also needs read_orders, and the
        403 names the scope that is missing. The WebhookTopic schema lists every
        subscribable topic with the scope it costs.
      operationId: post_webhook_endpoints
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks
      x-required-scopes: [write_webhooks]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, subscribed_events]
              additionalProperties: false
              description: >-
                THE RESPONSE CARRIES THE SIGNING SECRET AND NOTHING ELSE EVER WILL.
                Store it before doing anything else: no read returns it, and the only
                way back is POST
                /api/v1/webhook-endpoints/{webhookEndpointId}/rotate-secret, which
                issues a new one rather than showing this one again.
              properties:
                url:
                  type: string
                  description: >-
                    Where deliveries are sent. Must be an https address on the public
                    internet; private, loopback and link-local addresses are refused,
                    since an endpoint pointing inside the platform's own network would
                    make this API a request forwarder.
                subscribed_events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookTopic" }
                  description: >-
                    The event types this endpoint receives, at least one. Spelled
                    subscribed_events in both directions, matching the field it is
                    served back under. An unregistered type is refused HERE rather
                    than accepted and never delivered, so a typo is a 400 instead of a
                    receiver that silently never fires.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auto_disabled_at:
                        nullable: true
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      secret:
                        type: string
                      subscribed_events:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      url:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-endpoints/{webhookEndpointId}:
    delete:
      summary: Remove a webhook endpoint
      description: >-
        Removes the endpoint. Deliveries stop and the subscription cannot be
        restored; PATCH is_active false is the reversible form of the same intent,
        and it keeps the endpoint's id and secret.
        NEEDS BOTH GRANTS. The qualified scope named above is refused to a key
        holding write_webhooks alone, so ask the merchant for both.
        IDEMPOTENCY HERE IS PER KEY, NOT PER RESOURCE. Replaying the same
        Idempotency-Key returns the stored 204 without touching anything. A FRESH
        key against an endpoint that is already gone is 404 endpoint_not_found,
        because "your retry already landed" and "there is no such endpoint" are
        different facts, and answering 204 to a mistyped id would read as a
        deregistration that never happened while the real endpoint kept firing.
      operationId: delete_webhook_endpoints_by_webhookEndpointId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks:delete
      x-required-scopes: [write_webhooks, write_webhooks:delete]
      parameters:
        - name: webhookEndpointId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      responses:
        "204":
          description: Success. The response has no body.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks a required scope. `code` is `insufficient_scope`. This
            route is gated TWICE, on write_webhooks and then on write_webhooks:delete,
            and the message names whichever gate refused. A key holding neither is
            refused naming write_webhooks, because that gate runs first: seeing the
            parent named does NOT mean the qualifier would have been enough. Ask
            the merchant for both. The qualifier cannot be granted on its own in
            any case, since it narrows the parent rather than replacing it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    get:
      summary: Retrieve a webhook endpoint
      description: >-
        One endpoint. Carries no signing secret, for the reason given on the list.
      operationId: get_webhook_endpoints_by_webhookEndpointId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_webhooks
      x-required-scopes: [read_webhooks]
      parameters:
        - name: webhookEndpointId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auto_disabled_at:
                        nullable: true
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      subscribed_events:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      url:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
    patch:
      summary: Update a webhook endpoint
      description: >-
        Updates an endpoint in place, keeping its id and its signing secret.
        A subscribed_events you send is re-checked topic by topic against the same
        read scopes the create required, so a key cannot widen a subscription into a
        family it cannot read by editing an endpoint it already owns.
      operationId: patch_webhook_endpoints_by_webhookEndpointId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks
      x-required-scopes: [write_webhooks]
      parameters:
        - name: webhookEndpointId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Every field is optional and an omitted one keeps its stored value.
                The response is the endpoint resource, WITHOUT the signing secret;
                changing a subscription does not change the secret.
              properties:
                url:
                  type: string
                  description: Same address policy as the create.
                subscribed_events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookTopic" }
                  description: >-
                    REPLACES the whole subscription rather than adding to it, so send
                    the list you want rather than a diff. A present but empty array is
                    refused: omit the key to leave the subscription alone. A changed
                    list is re-checked against the read scope of every family it
                    names.
                is_active:
                  type: boolean
                  description: >-
                    false stops deliveries and is REVERSIBLE, which is what this API
                    offers instead of deleting. An endpoint the platform disabled
                    itself after sustained failure reads back with auto_disabled_at
                    set, so you can tell "the merchant turned this off" from "your
                    receiver stopped answering".
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auto_disabled_at:
                        nullable: true
                      created_at:
                        type: string
                      id:
                        type: string
                      is_active:
                        type: boolean
                      subscribed_events:
                        type: array
                        items:
                          type: string
                      updated_at:
                        type: string
                      url:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "422":
          $ref: "#/components/responses/UnprocessableReference"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-endpoints/{webhookEndpointId}/rotate-secret:
    post:
      summary: Issue a new signing secret for a webhook endpoint
      description: >-
        Issues a new signing secret and returns it once. Both the old and the new
        secret sign every delivery for seven days, so this is a window rather than a
        cutover.
      operationId: post_webhook_endpoints_by_webhookEndpointId_rotate_secret
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks
      x-required-scopes: [write_webhooks]
      parameters:
        - name: webhookEndpointId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected. The response is
                the new secret, shown once.
                IT IS NOT A CUTOVER. For seven days both the old and the new secret
                sign every delivery, so you redeploy your receiver on your own
                schedule rather than at the instant of this call. Rotating again
                inside that window is refused with 409 rotation_in_progress, because
                it would discard the secret you may not have replaced yet.
                This is also the recovery path if the create response was lost: it
                keeps the endpoint and its id, which deleting and recreating would
                not.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      secret:
                        type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/webhook-endpoints/{webhookEndpointId}/test:
    post:
      summary: Send a test delivery to a webhook endpoint
      description: >-
        Sends a real delivery to the endpoint's URL so you can confirm your receiver
        answers and your signature check passes.
      operationId: post_webhook_endpoints_by_webhookEndpointId_test
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: write_webhooks
      x-required-scopes: [write_webhooks]
      parameters:
        - name: webhookEndpointId
          in: path
          required: true
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string }
          description: >-
            A unique key per logical write. Replaying a request with the
            same key returns the first response byte for byte instead of
            applying the write twice.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: >-
                Takes no parameters, and an empty body is expected.
                USE A FRESH Idempotency-Key ON EVERY CALL. A replayed key returns the
                stored response without re-running anything, so a second test on the
                same key answers with the first result and sends nothing.
                It is a WRITE rather than a probe: it opens a real connection to your
                URL and records a row in the delivery log under the event type
                webhook.test. 200 means the request was carried out; whether your
                server answered is the delivered field.
              properties: {}
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      delivered:
                        type: boolean
                      error:
                        type: [string, "null"]
                      status_code:
                        type: [integer, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the write_webhooks scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/wishlist-items:
    get:
      summary: List wishlist items
      description: >-
        Every product a shopper on this store has saved and not bought, newest first. This
        is the record back-in-stock, price-drop and win-back programmes are built on, and
        it was reachable only from the storefront until now, which authenticates a shopper
        rather than the merchant.
        ONE LIST ANSWERS BOTH QUESTIONS. ?customer_id= is "what has this person saved",
        which is what the storefront widget shows; ?product_id= is "who is waiting on this
        product", which the storefront cannot ask at all and which is the reason to
        integrate. Send both to intersect them, or neither for the store's whole wishlist,
        which is the shape a first sync wants.
        AN ID THAT NAMES NOTHING IS AN EMPTY PAGE, not a 404. The honest answer for a
        customer with nothing saved and for a customer who does not exist is the same
        empty list, and answering not-found to the second would make this route a probe
        for which customer ids exist in the store.
        PAGED BY CREATION TIME, which is the only ordered column: wishlist_item has no
        updated_at, and a row is written once and never rewritten, so re-saving a product
        the shopper already has keeps the original date rather than refreshing it. Read
        created_at as "wanted since", which is what makes it worth segmenting on.
        THIS FAMILY IS READ ONLY and there is no write half to add later. Saving a product
        asserts that a NAMED PERSON wants it, and on this surface the caller supplies the
        customer id, so a key could write a row indistinguishable from one the shopper
        made by tapping a heart, which a back-in-stock programme would then send on.
        Removing one destroys a shopper's saved list with no restore. Both stay on the
        storefront, where the shopper owns the decision, and apiscope mints no
        write_wishlists at all.
      operationId: get_wishlist_items
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_wishlists
      x-required-scopes: [read_wishlists]
      parameters:
        - name: customer_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow the page to one shopper's saved products, which is what the storefront
            wishlist widget shows. NOT validated against the customer table: an id that
            names nobody returns an empty page rather than a 404, because the honest
            answer for a customer with nothing saved is the same empty list, and
            not-found would make this a probe for which customer ids exist.
        - name: product_id
          in: query
          required: false
          schema: { type: string }
          description: >-
            Narrow the page to everyone waiting on one product, which is the question a
            back-in-stock or price-drop programme asks and the storefront cannot answer at
            all. An id that names nothing is an empty page, as above. Send it together
            with customer_id to intersect the two rather than either being ignored.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
          description: >-
            Rows per page. Out of range is a 400 rather than a silent clamp, so a
            client asking for more than 100 learns it did not get it.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: >-
            The next_cursor from the previous page. Opaque: decode nothing from
            it and construct nothing by hand, since its encoding is not part of
            this contract. Omit it to read the first page.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        created_at:
                          type: string
                        customer_id:
                          type: string
                        id:
                          type: string
                        product_id:
                          type: string
                        variant_id:
                          type: [string, "null"]
                  page:
                    type: object
                    properties:
                      has_more:
                        type: boolean
                      next_cursor:
                        type: string
                        description: >-
                          Pass as ?after= to fetch the next page. Absent on the last page.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_wishlists scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
  /api/v1/wishlist-items/{wishlistItemId}:
    get:
      summary: Retrieve a wishlist item
      description: >-
        One wishlist entry, in the same shape the list serves, so a row read from a page
        can be re-read on its own rather than by walking the list again.
        The id is the ENTRY's, not the customer's. An entry belonging to another store is
        a 404 here exactly as it is invisible in the list.
      operationId: get_wishlist_items_by_wishlistItemId
      security:
        - apiKey: []
        - apiKeyHeader: []
      x-required-scope: read_wishlists
      x-required-scopes: [read_wishlists]
      parameters:
        - name: wishlistItemId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created_at:
                        type: string
                      customer_id:
                        type: string
                      id:
                        type: string
                      product_id:
                        type: string
                      variant_id:
                        type: [string, "null"]
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The key lacks the read_wishlists scope. `code` is `insufficient_scope` and the
            message names the scope to ask the merchant for.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        default:
          $ref: "#/components/responses/Unexpected"
webhooks:
  merchantEvent:
    post:
      summary: An event delivered to your endpoint
      operationId: webhook_delivery
      description: |-
        What this platform POSTs to the url you registered with POST
        /api/v1/webhook-endpoints. Nothing here is a route you call.

        VERIFY THE SIGNATURE ON EVERY DELIVERY. This is the only thing that
        distinguishes us from anyone who learns your endpoint url.

        The scheme is Standard Webhooks (standardwebhooks.com), so any
        Standard Webhooks library verifies these deliveries without custom
        code. To implement it yourself:

        1. The HMAC key is the secret with its `whsec_` prefix REMOVED and the
           remainder base64-decoded to raw bytes. It is not the string as
           returned, and using that string directly is the single most common
           reason a verifier never matches.
        2. The signed input is the three values joined by full stops:
           `webhook-id`, `webhook-timestamp`, then the RAW request body,
           as `id.timestamp.payload`. Sign the bytes you received; do not
           parse and re-serialise the JSON first, which changes them.
        3. Compute HMAC-SHA256 over that input with the key from step 1 and
           base64-encode the result.
        4. Compare it against the `webhook-signature` header, whose value is
           `v1,<base64>`. Compare in constant time.

        THE HEADER CAN CARRY MORE THAN ONE SIGNATURE, SPACE-SEPARATED, and
        the delivery is authentic if ANY of them verifies. Do not compare the
        header for equality: split it on spaces and accept on the first
        match. This is not hypothetical. POST
        /api/v1/webhook-endpoints/{webhookEndpointId}/rotate-secret signs
        every delivery with BOTH the old and the new secret for 7 days,
        which is what lets you redeploy on your own schedule, and a verifier
        that expects exactly one signature rejects every delivery for that
        entire window.

        Reject a delivery whose `webhook-timestamp` is far from your own clock,
        five minutes being the usual tolerance. Without that check a signed
        delivery captured once can be replayed at you forever.

        DELIVERY IS AT-LEAST-ONCE. The same event WILL reach you more than
        once eventually, and your handler has to be ready for it. Keep a
        record of the `webhook-id` values you have processed and ignore one
        you have seen before: it is stable across every retry of the same
        delivery, which is exactly what makes it the deduplication key.

        A delivery is retried on any non-2xx response and on any transport
        failure, up to 8 attempts including the first. The delays between
        attempts are 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and then 6 hours.
        A receiver that stays broken therefore has roughly 10 hours to
        recover before the delivery is abandoned, after which only a manual
        replay through POST /api/v1/webhook-deliveries/{deliveryId}/redeliver
        can recover it.

        A TIMEOUT IS NOT A FAILURE WE CAN TELL FROM ONE. You have
        5 seconds to answer, and a receiver that commits its work and then
        exceeds that is retried even though it succeeded. Acknowledge with a
        2xx first and do the slow work asynchronously. This is the mirror of
        the Idempotency-Key this API requires from you on every write: the
        same defence is needed in the other direction.
      parameters:
        - name: webhook-id
          in: header
          required: true
          schema: { type: string }
          description: >-
            Unique id for this delivery, stable across every retry of it. Deduplicate on this value.
        - name: webhook-timestamp
          in: header
          required: true
          schema: { type: string }
          description: >-
            Unix seconds at which the delivery was signed. Part of the signed input, and what you check to refuse a replayed capture.
        - name: webhook-signature
          in: header
          required: true
          schema: { type: string }
          description: >-
            One or more space-separated signatures, each `v1,<base64 hmac-sha256>`. Accept if any verifies.
        - name: webhook-event-type
          in: header
          required: true
          schema: { type: string }
          description: >-
            The topic that fired, matching the envelope's `event` field. Lets you route before parsing the body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEventPayload"
            examples:
              customers_created:
                summary: customers/created
                value: {
                "data": {
                  "created_at": "2026-01-02T03:04:05Z",
                  "email": "ada@example.com",
                  "first_name": "Ada",
                  "id": "cust_01HGOLDEN0000000000000001",
                  "last_name": null,
                  "phone": null
                },
                "event": "customers/created",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              customers_deleted:
                summary: customers/deleted
                value: {
                "data": {
                  "id": "cust_01HGOLDEN0000000000000001"
                },
                "event": "customers/deleted",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              customers_updated:
                summary: customers/updated
                value: {
                "data": {
                  "created_at": "2026-01-02T03:04:05Z",
                  "email": "ada@example.com",
                  "first_name": "Ada",
                  "id": "cust_01HGOLDEN0000000000000001",
                  "last_name": null,
                  "phone": null
                },
                "event": "customers/updated",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              fulfillments_created:
                summary: fulfillments/created
                value: {
                "data": {
                  "carrier": null,
                  "created_at": "2026-01-02T03:04:05Z",
                  "id": "ful_01HGOLDEN00000000000000001",
                  "items": [
                    {
                      "order_line_item_id": "oli_01HGOLDEN00000000000000001",
                      "quantity": 2
                    },
                    {
                      "order_line_item_id": "oli_01HGOLDEN00000000000000002",
                      "quantity": 1
                    }
                  ],
                  "order_id": "order_01HGOLDEN000000000000001",
                  "shipped_at": null,
                  "status": "shipped",
                  "tracking_number": null
                },
                "event": "fulfillments/created",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              fulfillments_updated:
                summary: fulfillments/updated
                value: {
                "data": {
                  "carrier": "ups",
                  "created_at": "2026-01-02T03:04:05Z",
                  "id": "ful_01HGOLDEN00000000000000001",
                  "items": [
                    {
                      "order_line_item_id": "oli_01HGOLDEN00000000000000001",
                      "quantity": 2
                    },
                    {
                      "order_line_item_id": "oli_01HGOLDEN00000000000000002",
                      "quantity": 1
                    }
                  ],
                  "order_id": "order_01HGOLDEN000000000000001",
                  "shipped_at": "2026-01-02T03:04:05Z",
                  "status": "shipped",
                  "tracking_number": "1Z999AA10123456784"
                },
                "event": "fulfillments/updated",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_adjusted:
                summary: gift_cards/adjusted
                value: {
                "data": {
                  "amount": -500,
                  "balance": 3000,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": null,
                  "status": "active"
                },
                "event": "gift_cards/adjusted",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_disabled:
                summary: gift_cards/disabled
                value: {
                "data": {
                  "amount": null,
                  "balance": 3500,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": null,
                  "status": "disabled"
                },
                "event": "gift_cards/disabled",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_enabled:
                summary: gift_cards/enabled
                value: {
                "data": {
                  "amount": null,
                  "balance": 3500,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": null,
                  "status": "active"
                },
                "event": "gift_cards/enabled",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_expired:
                summary: gift_cards/expired
                value: {
                "data": {
                  "amount": null,
                  "balance": 3000,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": null,
                  "status": "expired"
                },
                "event": "gift_cards/expired",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_issued:
                summary: gift_cards/issued
                value: {
                "data": {
                  "amount": 5000,
                  "balance": 5000,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": null,
                  "status": "active"
                },
                "event": "gift_cards/issued",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              gift_cards_redeemed:
                summary: gift_cards/redeemed
                value: {
                "data": {
                  "amount": -1500,
                  "balance": 3500,
                  "currency_code": "usd",
                  "display_prefix": "GIFT",
                  "expires_at": "2027-01-02T03:04:05Z",
                  "id": "gc_01HGOLDEN0000000000000001",
                  "order_id": "order_01HGOLDEN00000000000001",
                  "status": "active"
                },
                "event": "gift_cards/redeemed",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              inventory_updated:
                summary: inventory/updated
                value: {
                "data": {
                  "available_quantity": 37,
                  "id": "invlvl_01HGOLDEN00000000000001",
                  "location_id": "sloc_01HGOLDEN0000000000000001",
                  "reserved_quantity": 5,
                  "stocked_quantity": 42,
                  "variant_id": "variant_01HGOLDEN000000000000001"
                },
                "event": "inventory/updated",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              orders_cancelled:
                summary: orders/cancelled
                value: {
                "data": {
                  "amount_due": 4609,
                  "created_at": "2026-01-02T03:04:05Z",
                  "currency_code": "usd",
                  "customer_id": "cust_01HGOLDEN0000000000000001",
                  "discount_total": 400,
                  "display_id": 1042,
                  "email": "ada@example.com",
                  "id": "order_01HGOLDEN000000000000001",
                  "line_items": [
                    {
                      "id": "oli_01HGOLDEN00000000000000001",
                      "product_title": "Cool Tee",
                      "quantity": 2,
                      "unit_price": 1999,
                      "variant_id": "variant_01HGOLDEN000000000000001",
                      "variant_title": "S / Red"
                    },
                    {
                      "id": "oli_01HGOLDEN00000000000000002",
                      "product_title": "Cool Tee",
                      "quantity": 1,
                      "unit_price": 2099,
                      "variant_id": "variant_01HGOLDEN000000000000002",
                      "variant_title": "M / Red"
                    }
                  ],
                  "shipping_total": 599,
                  "status": "paid",
                  "subtotal": 4098,
                  "tax_total": 312,
                  "total": 4609
                },
                "event": "orders/cancelled",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              orders_fulfilled:
                summary: orders/fulfilled
                value: {
                "data": {
                  "amount_due": 4609,
                  "created_at": "2026-01-02T03:04:05Z",
                  "currency_code": "usd",
                  "customer_id": "cust_01HGOLDEN0000000000000001",
                  "discount_total": 400,
                  "display_id": 1042,
                  "email": "ada@example.com",
                  "id": "order_01HGOLDEN000000000000001",
                  "line_items": [
                    {
                      "id": "oli_01HGOLDEN00000000000000001",
                      "product_title": "Cool Tee",
                      "quantity": 2,
                      "unit_price": 1999,
                      "variant_id": "variant_01HGOLDEN000000000000001",
                      "variant_title": "S / Red"
                    },
                    {
                      "id": "oli_01HGOLDEN00000000000000002",
                      "product_title": "Cool Tee",
                      "quantity": 1,
                      "unit_price": 2099,
                      "variant_id": "variant_01HGOLDEN000000000000002",
                      "variant_title": "M / Red"
                    }
                  ],
                  "shipping_total": 599,
                  "status": "paid",
                  "subtotal": 4098,
                  "tax_total": 312,
                  "total": 4609
                },
                "event": "orders/fulfilled",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              orders_paid:
                summary: orders/paid
                value: {
                "data": {
                  "amount_due": 4609,
                  "created_at": "2026-01-02T03:04:05Z",
                  "currency_code": "usd",
                  "customer_id": "cust_01HGOLDEN0000000000000001",
                  "discount_total": 400,
                  "display_id": 1042,
                  "email": "ada@example.com",
                  "id": "order_01HGOLDEN000000000000001",
                  "line_items": [
                    {
                      "id": "oli_01HGOLDEN00000000000000001",
                      "product_title": "Cool Tee",
                      "quantity": 2,
                      "unit_price": 1999,
                      "variant_id": "variant_01HGOLDEN000000000000001",
                      "variant_title": "S / Red"
                    },
                    {
                      "id": "oli_01HGOLDEN00000000000000002",
                      "product_title": "Cool Tee",
                      "quantity": 1,
                      "unit_price": 2099,
                      "variant_id": "variant_01HGOLDEN000000000000002",
                      "variant_title": "M / Red"
                    }
                  ],
                  "shipping_total": 599,
                  "status": "paid",
                  "subtotal": 4098,
                  "tax_total": 312,
                  "total": 4609
                },
                "event": "orders/paid",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              orders_refunded:
                summary: orders/refunded
                value: {
                "data": {
                  "amount_due": 4609,
                  "created_at": "2026-01-02T03:04:05Z",
                  "currency_code": "usd",
                  "customer_id": "cust_01HGOLDEN0000000000000001",
                  "discount_total": 400,
                  "display_id": 1042,
                  "email": "ada@example.com",
                  "id": "order_01HGOLDEN000000000000001",
                  "line_items": [
                    {
                      "id": "oli_01HGOLDEN00000000000000001",
                      "product_title": "Cool Tee",
                      "quantity": 2,
                      "unit_price": 1999,
                      "variant_id": "variant_01HGOLDEN000000000000001",
                      "variant_title": "S / Red"
                    },
                    {
                      "id": "oli_01HGOLDEN00000000000000002",
                      "product_title": "Cool Tee",
                      "quantity": 1,
                      "unit_price": 2099,
                      "variant_id": "variant_01HGOLDEN000000000000002",
                      "variant_title": "M / Red"
                    }
                  ],
                  "shipping_total": 599,
                  "status": "paid",
                  "subtotal": 4098,
                  "tax_total": 312,
                  "total": 4609
                },
                "event": "orders/refunded",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              products_created:
                summary: products/created
                value: {
                "data": {
                  "handle": "cool-tee",
                  "id": "prod_01HGOLDEN0000000000000000",
                  "images": [
                    "https://cdn.example/tee-front.png",
                    "https://cdn.example/tee-back.png"
                  ],
                  "published_at": "2026-01-02T03:04:05Z",
                  "status": "published",
                  "title": "Cool Tee",
                  "variants": [
                    {
                      "currency_code": "usd",
                      "id": "variant_01HGOLDEN000000000000001",
                      "price_minor": 1999,
                      "sku": "TEE-S-RED",
                      "title": "S / Red"
                    },
                    {
                      "currency_code": "usd",
                      "id": "variant_01HGOLDEN000000000000002",
                      "price_minor": 2099,
                      "sku": null,
                      "title": "M / Red"
                    }
                  ]
                },
                "event": "products/created",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              products_deleted:
                summary: products/deleted
                value: {
                "data": {
                  "id": "prod_01HGOLDEN0000000000000000"
                },
                "event": "products/deleted",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              products_updated:
                summary: products/updated
                value: {
                "data": {
                  "handle": "cool-tee",
                  "id": "prod_01HGOLDEN0000000000000000",
                  "images": [
                    "https://cdn.example/tee-front.png",
                    "https://cdn.example/tee-back.png"
                  ],
                  "published_at": "2026-01-02T03:04:05Z",
                  "status": "published",
                  "title": "Cool Tee",
                  "variants": [
                    {
                      "currency_code": "usd",
                      "id": "variant_01HGOLDEN000000000000001",
                      "price_minor": 1999,
                      "sku": "TEE-S-RED",
                      "title": "S / Red"
                    },
                    {
                      "currency_code": "usd",
                      "id": "variant_01HGOLDEN000000000000002",
                      "price_minor": 2099,
                      "sku": null,
                      "title": "M / Red"
                    }
                  ]
                },
                "event": "products/updated",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              reviews_created:
                summary: reviews/created
                value: {
                "data": {
                  "body": "Fits well and the colour held up.",
                  "created_at": "2026-01-02T03:04:05Z",
                  "customer_id": null,
                  "id": "rev_01HGOLDEN0000000000000001",
                  "product_id": "prod_01HGOLDEN0000000000000000",
                  "rating": 5,
                  "status": "published",
                  "title": "Great tee",
                  "verified_purchase": true
                },
                "event": "reviews/created",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
              reviews_updated:
                summary: reviews/updated
                value: {
                "data": {
                  "body": "Fits well and the colour held up.",
                  "created_at": "2026-01-02T03:04:05Z",
                  "customer_id": null,
                  "id": "rev_01HGOLDEN0000000000000001",
                  "product_id": "prod_01HGOLDEN0000000000000000",
                  "rating": 5,
                  "status": "rejected",
                  "title": "Great tee",
                  "verified_purchase": true
                },
                "event": "reviews/updated",
                "occurred_at": "2026-01-02T03:04:05Z",
                "schema_version": "1"
              }
      responses:
        "200":
          description: >-
            Any 2xx acknowledges the delivery and it is not retried. Answer
            before doing slow work. A body is read and discarded.
        default:
          description: >-
            Any other status, or no response at all, is a failure and the
            delivery is retried on the schedule above. Sustained failure
            eventually disables the endpoint, and the merchant is told.
