openapi: 3.1.0
info:
  title: Platform (Markets, Segments, Auth, Billing)
  version: 2.0.0
  description: |
    Shared platform endpoints for markets, audience segments, authentication, billing, health checks, and search.
  contact:
    name: Motionworks AI
    url: https://mworks.com
    email: api@mworks.com
servers:
  - url: https://api.mworks.com/v2
    description: Production
components:
  securitySchemes:
    apiKey:
      type: apiKey
      name: X-API-Key
      in: header
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Market:
      type: object
      properties:
        market_id:
          type: string
        name:
          type: string
        dma_code:
          type: string
        population:
          type: integer
        households:
          type: integer
        spots_count:
          type: integer
        latitude:
          type: number
          format: double
        longitude:
          type: number
          format: double
        zoom:
          type: integer
    Segment:
      type: object
      properties:
        segment_id:
          type: string
        name:
          type: string
        description:
          type: string
        category:
          type: string
          enum:
            - Demographic
            - Behavioral
            - Lifestyle
            - Purchase Intent
            - Custom
        universe_size:
          type: integer
    CreditBalance:
      type: object
      properties:
        org_id:
          type: string
        tier:
          type: string
          enum:
            - sandbox
            - growth
            - enterprise
        credits_used:
          type: integer
        credits_remaining:
          type: integer
        credits_total:
          type: integer
        reset_at:
          type: string
          format: date-time
    HealthStatus:
      type: object
      properties:
        status:
          type: string
          enum:
            - healthy
            - degraded
            - down
        version:
          type: string
        timestamp:
          type: string
          format: date-time
    DataFreshness:
      type: object
      properties:
        latest_measurement_period:
          type: string
          format: date
        profiles_updated_count:
          type: integer
        next_expected_refresh:
          type: string
          format: date
        data_sources:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              last_updated:
                type: string
                format: date-time
              status:
                type: string
    CreateApiKeyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Human-readable label for the key. Trimmed.
        scope:
          type: string
          enum:
            - read
          description: |
            Reserved. MVP forces server-side `read`; any other value
            returns 400 `INVALID_SCOPE`. The column exists on `api_keys`
            but `validate_key` doesn't enforce it on read-path operations,
            so configurable scope before enforcement would be a
            CVE-shaped contract. Future ADR will enable narrower scopes
            once enforcement lands.
    CreateApiKeyResponse:
      type: object
      required:
        - id
        - key
        - name
        - prefix
        - scope
        - _warning
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: Plaintext API key. Returned exactly once — see `_warning`.
        name:
          type: string
        prefix:
          type: string
        scope:
          type: string
          enum:
            - read
        created_at:
          type: string
          format: date-time
        _warning:
          type: string
    ApiKeyListItem:
      type: object
      required:
        - id
        - name
        - prefix
        - scope
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        prefix:
          type: string
        scope:
          type: string
        status:
          type: string
          enum:
            - active
            - rotating
            - revoked
            - expired
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
    RotateApiKeyResponse:
      type: object
      required:
        - id
        - key
        - prefix
        - _warning
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: New plaintext API key. Returned exactly once.
        prefix:
          type: string
        scope:
          type: string
          enum:
            - read
        rotation_expires_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp when the OLD key's 24h grace expires. `null` ONLY
            when `?immediate=true` was passed AND the follow-on revoke
            succeeded (old key is dead). When `?immediate=true` is
            passed but the follow-on revoke fails (see
            `_revoke_failure`), this field is populated because the OLD
            key is still valid via `previous_key_hash` for the 24h
            grace window.
        _warning:
          type: string
        _revoke_failure:
          type: boolean
          enum:
            - true
          description: |
            Present and `true` only when `?immediate=true` was passed,
            the rotate succeeded, and the follow-on `revoke_key` call
            failed. In that case the response still returns the new
            plaintext key (status 200), `rotation_expires_at` is
            populated to reflect that the OLD key remains valid for
            the 24h grace, and the caller SHOULD retry
            `DELETE /v2/account/keys/{id}` to revoke the old key
            immediately.
        _revoke_failure_message:
          type: string
          description: Human-readable explanation paired with `_revoke_failure`.
    PricingOperation:
      type: object
      required:
        - id
        - display_name
        - credits_per_call
        - usd_per_call
        - qualifier
        - endpoints
      properties:
        id:
          type: string
        display_name:
          type: string
        credits_per_call:
          type: number
          minimum: 0
        usd_per_call:
          type: number
          minimum: 0
        qualifier:
          type: string
          nullable: true
        endpoints:
          type: array
          items:
            type: string
        status:
          type: string
          enum:
            - production
            - planned
            - roadmap
        planned_endpoint:
          type: string
        note:
          type: string
    PricingSku:
      type: object
      required:
        - id
        - billing_cadence
      properties:
        id:
          type: string
        display_name:
          type: string
        billing_cadence:
          type: string
          enum:
            - monthly
            - annual
            - one-time
        price_usd:
          type: number
          nullable: true
        included_calls:
          type: integer
          nullable: true
        included_calls_per_year:
          type: integer
          nullable: true
        expected_calls:
          type: integer
          nullable: true
        expected_calls_per_year:
          type: integer
          nullable: true
        included_operation_id:
          type: string
        status:
          type: string
          enum:
            - production
            - planned
            - roadmap
        roadmap_issue:
          type: string
        included_locations:
          type: integer
          nullable: true
        rate_per_location_yr:
          type: number
        annual_minimum_usd:
          type: number
        scope:
          type: string
        place_universe:
          type: string
        price_usd_per_event:
          type: number
        included_select_events_per_year:
          type: integer
        overage_select_event_usd:
          type: number
        flexible_composition:
          type: boolean
        note:
          type: string
    PricingPlatformSku:
      type: object
      required:
        - id
        - billing_cadence
      properties:
        id:
          type: string
        display_name:
          type: string
        billing_cadence:
          type: string
          enum:
            - monthly
            - annual
            - one-time
        price_usd:
          type: number
          nullable: true
        weekly_impression_tier:
          type: string
        included_calls_per_year:
          type: integer
          nullable: true
        max_events:
          type: integer
          nullable: true
        price_usd_per_event:
          type: number
        pricing_basis:
          type: string
        cap:
          type: string
        included_campaigns_per_year:
          type: integer
        status:
          type: string
          enum:
            - production
            - planned
            - roadmap
        note:
          type: string
    PricingAddon:
      type: object
      required:
        - id
        - billing_cadence
        - unit
      properties:
        id:
          type: string
        display_name:
          type: string
        billing_cadence:
          type: string
          enum:
            - monthly
            - annual
            - one-time
        price_usd_per_unit:
          type: number
          nullable: true
        price_usd_per_unit_pct_of_base:
          type: number
        unit:
          type: string
        max_units:
          type: integer
        kind:
          type: string
          enum:
            - registration_and_measurement
            - standing_definition
            - standing_definition_with_usage_budget
        planned_endpoint:
          type: string
        implied_credits_per_unit:
          type: number
        attaches_to:
          type: array
          items:
            type: string
        note:
          type: string
    PricingCrossCuttingOperation:
      type: object
      required:
        - id
        - credits_per_call
        - endpoints
      properties:
        id:
          type: string
        display_name:
          type: string
        credits_per_call:
          type: number
          minimum: 0
        usd_per_call:
          type: number
          minimum: 0
        qualifier:
          type: string
          nullable: true
        endpoints:
          type: array
          items:
            type: string
        note:
          type: string
paths:
  /markets:
    get:
      operationId: listMarkets
      summary: List all DMA markets
      x-credit-cost: 1
      security:
        - apiKey: []
      parameters:
        - name: include_metrics
          in: query
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Array of markets
  /markets/{id}:
    get:
      operationId: getMarket
      summary: Get market detail
      x-credit-cost: 1
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Market detail
        '404':
          description: Market not found
  /segments:
    get:
      operationId: listSegments
      summary: List audience segments
      x-credit-cost: 1
      security:
        - apiKey: []
      responses:
        '200':
          description: Array of segments
  /segments/{id}:
    get:
      operationId: getSegment
      summary: Get segment detail
      x-credit-cost: 1
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Segment detail
        '404':
          description: Segment not found
  /health:
    get:
      operationId: getHealth
      summary: API health check
      x-credit-cost: 0
      security: []
      responses:
        '200':
          description: Health status
  /health/data:
    get:
      operationId: getDataFreshness
      summary: Data freshness status
      x-credit-cost: 0
      security: []
      responses:
        '200':
          description: Data freshness info
  /account/keys:
    post:
      operationId: createAccountKey
      summary: Mint a new API key for the caller's organization
      description: |
        Auth: `Authorization: Bearer <user JWT>`. NOT `X-API-Key` — by
        design you manage keys with a JWT and you USE keys to call data
        endpoints. See ADR-012 + the three-header model.

        The `scope` field is reserved: MVP forces `read` server-side. Any
        other value returns 400 `INVALID_SCOPE`. Tier cap is enforced
        inside `generate_api_key` (FOR UPDATE on the org row); a 409
        `KEY_LIMIT_REACHED` is returned when the org has reached its
        tier's `plans.features.max_keys` ceiling.

        Rate-limited 10/min per user; fail-closed in production when the
        RATE_LIMIT KV binding is unavailable.
      tags:
        - account
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      parameters:
        - name: org_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: |
            Required only when the caller has memberships in more than
            one organization. Otherwise inferred from the single membership.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateApiKeyRequest'
      responses:
        '201':
          description: Key created
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    $ref: '#/components/schemas/CreateApiKeyResponse'
                  meta:
                    type: object
        '400':
          description: Validation error (empty body, name out of range, non-`read` scope → INVALID_SCOPE)
        '401':
          description: Missing or invalid Authorization header
        '403':
          description: Caller has insufficient permissions for this org
        '404':
          description: Caller has no organization membership (NO_ORG)
        '409':
          description: Multiple org memberships (MULTIPLE_ORGS) or tier cap reached (KEY_LIMIT_REACHED)
        '429':
          description: Rate limited
    get:
      operationId: listAccountKeys
      summary: List the caller's organization API keys
      description: |
        Auth: `Authorization: Bearer <user JWT>`. Returns key metadata.
        **Never** returns `key_hash`, `previous_key_hash`, or the
        plaintext `key` — those exist only at mint/rotate time.
      tags:
        - account
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      parameters:
        - name: include_revoked
          in: query
          required: false
          schema:
            type: boolean
            default: false
        - name: org_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Required only when the caller has multiple org memberships.
      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiKeyListItem'
                  meta:
                    type: object
        '401':
          description: Missing or invalid Authorization header
        '404':
          description: No organization membership (NO_ORG)
        '409':
          description: Multiple org memberships (MULTIPLE_ORGS)
        '429':
          description: Rate limited
  /account/keys/{id}/rotate:
    post:
      operationId: rotateAccountKey
      summary: Rotate an API key (24h grace by default)
      description: |
        Rotates the key in place. The OLD key stays valid for 24 hours
        unless `?immediate=true` is passed (in which case the old key is
        revoked synchronously). Returns the NEW plaintext key exactly
        once.

        Cache invalidation: the `ORG_CACHE` entry for the old key hash is
        synchronously deleted BEFORE the response is sent. This is
        load-bearing — a `waitUntil`-based delete leaves a 60s window
        where the cache still validates the now-rotating key.
      tags:
        - account
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: immediate
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: When `true`, revoke the old key immediately (no 24h grace).
      responses:
        '200':
          description: Key rotated
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    $ref: '#/components/schemas/RotateApiKeyResponse'
                  meta:
                    type: object
        '400':
          description: Malformed uuid
        '401':
          description: Missing or invalid Authorization header
        '403':
          description: Caller lacks owner/admin role on the key's org
        '404':
          description: Key not found (or cross-org access — don't leak existence)
        '429':
          description: Rate limited
  /account/keys/{id}:
    delete:
      operationId: revokeAccountKey
      summary: Revoke an API key immediately
      description: |
        Revokes the key immediately. The `ORG_CACHE` entry for the key's
        hash is synchronously deleted BEFORE the 204 response is sent
        (Skeptic non-negotiable: async-after-response leaves a window
        where the cache still validates a revoked key).
      tags:
        - account
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Key revoked (no content)
        '400':
          description: Malformed uuid
        '401':
          description: Missing or invalid Authorization header
        '403':
          description: Caller lacks owner/admin role on the key's org
        '404':
          description: Key not found (or cross-org access)
        '429':
          description: Rate limited
  /billing/credits:
    get:
      operationId: getCreditBalance
      summary: Check credit balance
      x-credit-cost: 0
      security:
        - apiKey: []
      responses:
        '200':
          description: Credit balance
    post:
      operationId: createCreditsCheckoutSession
      summary: Create a Stripe Checkout session for credit pack, committed monthly plan, or pay-as-you-go
      description: |
        Creates a Stripe Checkout Session for one of:
          - one-time credit pack purchase (`price_pack_10k`/`100k`/`1M` → `mode=payment`,
            `success_url` `type=pack`),
          - recurring monthly committed plan (`price_sub_10k`/`100k` → `mode=subscription`,
            `success_url` `type=sub`),
          - pay-as-you-go metered subscription (`credits_metered` → `mode=subscription`,
            `success_url` `type=payg`). Stripe rejects `line_items[*][quantity]` on metered
            prices — quantity is derived from reported meter events — so quantity is omitted
            for `credits_metered`. PAYG carries no wallet top-up; credits flow via the
            Stripe Billing Meter API from the router after `deduct_credits` succeeds.

        Resolves the Stripe lookup key to a price ID at request time, attaches
        the caller's org context (via Supabase JWT → `org_members` lookup), and
        returns the hosted checkout URL inside a standard `{ data, meta }`
        envelope. Pack purchases additionally set `payment_intent_data.metadata`
        so a future `payment_intent.succeeded` webhook can top up the org's
        credit wallet (NOT set for `price_sub_*` or `credits_metered`).
        Auth REQUIRED. No credits charged.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - priceKey
              properties:
                priceKey:
                  type: string
                  enum:
                    - price_pack_10k
                    - price_pack_100k
                    - price_pack_1M
                    - price_sub_10k
                    - price_sub_100k
                    - credits_metered
                  description: Stripe lookup key for the desired credit pack, committed monthly subscription, or pay-as-you-go metered subscription
                returnUrl:
                  type: string
                  format: uri
                  description: |
                    Optional return URL the user is redirected to after Stripe
                    Checkout success or cancel. When valid, replaces the
                    default `${firstAllowedOrigin}/app/?checkout=success&type=<pack|sub|payg>`
                    / `${firstAllowedOrigin}/app/?checkout=cancel` URLs.
                    Appends `?checkout=success&type=<pack|sub|payg>` to
                    `success_url`, and `?checkout=cancel` (no `type` suffix)
                    to `cancel_url`. Caller-supplied query strings on
                    `returnUrl` are preserved; the server-set `checkout` and
                    `type` keys are authoritative (no duplicates).

                    Validation rules (enforced server-side):
                      * Scheme MUST be `https:` (rejects `javascript:`,
                        `data:`, `file:`, `http:`).
                      * Origin MUST exactly match an entry in the worker's
                        `ALLOWED_ORIGINS` env binding (no wildcards, no
                        suffix matching).
                      * URL fragment (`#...`) is rejected.

                    Missing or invalid values fall back to the existing
                    hardcoded URLs (backward-compatible).
                  example: https://console.mworks.com/console/
      responses:
        '200':
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
                        format: uri
                        description: Stripe Checkout hosted page URL
                  meta:
                    type: object
                    properties:
                      request_id:
                        type: string
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      product:
                        type: string
                      version:
                        type: string
                      provenance:
                        type: object
        '400':
          description: Invalid priceKey
        '401':
          description: Missing or invalid JWT
        '503':
          description: Stripe or Supabase not configured
  /billing/credit-schedule:
    get:
      operationId: getCreditWeightSchedule
      summary: '[DEPRECATED] Get the versioned credit weight schedule manifest'
      deprecated: true
      description: |
        SOFT-DEPRECATED per ADR-19b (2026-05-09). Sunset: **2026-06-08**.
        Use `GET /v2/billing/pricing` instead.

        Returns the complete per-endpoint credit cost table as a versioned JSON
        manifest (the original ADR-19 wire shape). No authentication required —
        this is public pricing information. Cached aggressively (1h CDN, 1d edge).

        During the soft-deprecation window:
        - The wire shape is unchanged so existing consumers don't break.
        - The response carries `Deprecation: true`, `Sunset: <HTTP-date>`, and
          `Link: <…/v2/billing/pricing>; rel="successor-version"` headers.
        - The same notice is mirrored in `body.meta.deprecation`.

        After 2026-06-08 a follow-up PR replaces the handler with `410 Gone`.
        Last documented consumer (www-mworks-com) migrated off via PR #332/#336.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: deprecated
      x-deprecated-sunset: '2026-06-08'
      x-successor: /v2/billing/pricing
      security: []
      responses:
        '200':
          description: Credit weight schedule manifest
          headers:
            Cache-Control:
              schema:
                type: string
              example: public, max-age=3600, s-maxage=86400
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - version
                      - ratePerCredit
                      - updatedAt
                      - endpoints
                    properties:
                      version:
                        type: string
                        example: 1.0.0
                      ratePerCredit:
                        type: number
                        example: 0.05
                      updatedAt:
                        type: string
                        format: date
                        example: '2026-05-05'
                      endpoints:
                        type: object
                        additionalProperties:
                          type: integer
                          minimum: 0
                        example:
                          GET /v2/placecast/profiles/:id: 2
                          GET /v2/placecast/profiles: 10
                          POST /v2/placecast/select: 25
                  meta:
                    type: object
                    properties:
                      request_id:
                        type: string
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      product:
                        type: string
                      version:
                        type: string
                      provenance:
                        type: object
  /billing/pricing:
    get:
      operationId: getPricingManifest
      summary: Get the versioned pricing manifest
      description: |
        Returns the canonical Motionworks Pricing Manifest as a versioned
        JSON document (ADR-19a). The manifest is the single source of truth
        for product-level pricing: SKUs, operations, addons, surfaces, and
        overage policies. The legacy `GET /v2/billing/credit-schedule`
        endpoint (ADR-19) is now a derived projection of this manifest;
        both URLs stay live.

        Two surfaces:
          - `products` — per-call PAYG (Popcast, Pathcast roadmap, Placecast
            Profiles / Select / Premium, Custom add-ons). Overage continues
            at the per-credit rate.
          - `platform` — subscription-tier with a hard `contact_sales` cutoff
            at quota (Viewcast Profiles, Campaign Measurement).

        No authentication required — this is public pricing information.
        Cached aggressively (1h CDN, 1d edge). The optional `?v=<version>`
        query parameter is a CDN-cache-bust handle only — most CDNs
        include the query string in the cache key, so a different `?v=`
        value misses the edge cache. The handler does NOT version-pin:
        it always returns the current manifest. Clients that need the
        version should read `data.version` from the response body.
        Consumed at build time by www-mworks-com (pricing calculator +
        pricing pages, Track 2).
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security: []
      parameters:
        - in: query
          name: v
          required: false
          schema:
            type: string
          description: |
            Optional CDN-cache-bust handle (e.g. `1.0.0`). The handler
            does not branch on this value; it's only useful because most
            CDNs include the query string in their cache key, so passing
            a different `?v=` forces an edge-cache miss. The served
            manifest version is always the current one and is carried in
            `data.version` in the response body.
      responses:
        '200':
          description: Pricing manifest
          headers:
            Cache-Control:
              schema:
                type: string
              example: public, max-age=3600, s-maxage=86400
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - $schema
                      - version
                      - updated_at
                      - currency
                      - credit
                      - overage_policy
                      - products
                      - platform
                      - reference_operations
                      - free_operations
                    properties:
                      $schema:
                        type: string
                        format: uri
                        example: https://docs.mworks.com/schemas/pricing/v1.json
                      version:
                        type: string
                        example: 1.0.0
                      updated_at:
                        type: string
                        format: date
                        example: '2026-05-08'
                      currency:
                        type: string
                        enum:
                          - USD
                      credit:
                        type: object
                        required:
                          - rate_per_credit_usd
                          - stripe_lookup_key_payg
                          - free_monthly_grant
                          - free_grant_accumulates
                        properties:
                          rate_per_credit_usd:
                            type: number
                            example: 0.05
                          stripe_lookup_key_payg:
                            type: string
                            example: credits_metered
                          free_monthly_grant:
                            type: integer
                            example: 2000
                          free_grant_accumulates:
                            type: boolean
                      overage_policy:
                        type: object
                        required:
                          - products
                          - platform
                        properties:
                          products:
                            type: string
                            enum:
                              - payg
                              - contact_sales
                          platform:
                            type: string
                            enum:
                              - payg
                              - contact_sales
                      products:
                        type: object
                        description: |
                          Map of API-metered products keyed by id (e.g.
                          `popcast`, `placecast_profiles`). Each entry
                          carries `display_name`, `kicker`, `operations`,
                          `skus`, and optional `addons`.
                        additionalProperties:
                          type: object
                          required:
                            - display_name
                            - kicker
                            - operations
                            - skus
                          properties:
                            display_name:
                              type: string
                            kicker:
                              type: string
                            status:
                              type: string
                              enum:
                                - production
                                - planned
                                - roadmap
                            operations:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingOperation'
                            skus:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingSku'
                            addons:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingAddon'
                      platform:
                        type: object
                        description: |
                          Map of subscription-tier products keyed by id
                          (e.g. `viewcast_profiles`, `campaign_measurement`).
                          Hard cutoff at quota — overage = `contact_sales`.
                        additionalProperties:
                          type: object
                          required:
                            - display_name
                            - kicker
                            - operations
                          properties:
                            display_name:
                              type: string
                            kicker:
                              type: string
                            status:
                              type: string
                              enum:
                                - production
                                - planned
                                - roadmap
                            operations:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingOperation'
                            skus:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingPlatformSku'
                            skus_subscription:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingPlatformSku'
                            skus_per_event:
                              type: array
                              items:
                                $ref: '#/components/schemas/PricingPlatformSku'
                            legacy_endpoints:
                              type: object
                              required:
                                - note
                              properties:
                                note:
                                  type: string
                      reference_operations:
                        type: array
                        description: |
                          Cross-cutting reference-tier operations (low-cost
                          lookups not owned by a single product).
                        items:
                          $ref: '#/components/schemas/PricingCrossCuttingOperation'
                      free_operations:
                        type: array
                        description: |
                          Cross-cutting 0-credit operations (health,
                          billing reads, redemption flow).
                        items:
                          $ref: '#/components/schemas/PricingCrossCuttingOperation'
                  meta:
                    type: object
                    properties:
                      request_id:
                        type: string
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      product:
                        type: string
                      version:
                        type: string
                      provenance:
                        type: object
  /billing/wallet:
    get:
      operationId: getWallet
      summary: Get the authenticated user's four-component wallet
      description: |
        Returns the caller's credit picture as three independent balance
        components plus the period bookends and the free-tier eligibility
        status (ADR-21).

        Balance components:
          - `free_tier_balance` — monthly free-tier grant remaining. Resets
            non-accumulating each UTC calendar month. Forced to `0` when
            `free_tier_eligibility_status !== 'eligible'`.
          - `paid_balance` — Stripe pay-as-you-go / pack top-ups +
            committed-subscription balance. Does not reset.
          - `grant_balance` — conference / redemption-code credits. Does
            not reset.

        Eligibility (`free_tier_eligibility_status`):
          - `eligible` — both gates satisfied; the monthly grant is lazy-
            issued on this call (idempotent via the unique index on
            `(user_id, period_start_utc)`).
          - `needs_verified_email` — `auth.users.email_confirmed_at IS NULL`.
          - `needs_card_on_file` — email verified but no Stripe
            payment_method on file. Today the card-on-file gate is stubbed
            behind `ENABLE_CARD_ON_FILE_GATE` (default off); the follow-up
            PR wires the Stripe SetupIntent lookup.

        Auth REQUIRED (Supabase JWT). 0 credits charged.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Wallet projection.
          content:
            application/json:
              schema:
                type: object
                required:
                  - free_tier_balance
                  - paid_balance
                  - grant_balance
                  - period_start_utc
                  - next_reset_utc
                  - free_tier_eligibility_status
                properties:
                  free_tier_balance:
                    type: integer
                    minimum: 0
                  paid_balance:
                    type: integer
                    minimum: 0
                  grant_balance:
                    type: integer
                    minimum: 0
                  period_start_utc:
                    type: string
                    format: date-time
                    example: '2026-05-01T00:00:00.000Z'
                  next_reset_utc:
                    type: string
                    format: date-time
                    example: '2026-06-01T00:00:00.000Z'
                  free_tier_eligibility_status:
                    type: string
                    enum:
                      - eligible
                      - needs_verified_email
                      - needs_card_on_file
        '401':
          description: |
            Missing, malformed, or expired bearer token; or a valid JWT for a
            hard-deleted user (GoTrue admin lookup returned 404).
          content:
            application/json:
              schema:
                type: object
                required:
                  - error
                properties:
                  error:
                    type: object
                    required:
                      - code
                      - message
                      - status
                      - request_id
                      - product
                      - docs_url
                    properties:
                      code:
                        type: string
                        enum:
                          - UNAUTHORIZED
                      message:
                        type: string
                      status:
                        type: integer
                        enum:
                          - 401
                      request_id:
                        type: string
                      product:
                        type: string
                        enum:
                          - platform
                      docs_url:
                        type: string
                        format: uri
        '500':
          description: |
            Internal error. Emitted when GoTrue admin user lookup fails (non-2xx,
            non-404), the org_members lookup fails (Supabase outage), the
            `recompute_grant` RPC raises (e.g. P0010 for a non-free plan), or
            the `read_wallet_projection` RPC fails. The handler does NOT silently
            fall through to plan='free' on org-lookup outages — those surface
            here with `error.code = INTERNAL_ERROR`.

            The `context.phase` field on the error tags which handler step
            failed so production 500s can be triaged without log access (the
            underlying error detail stays in worker logs and is never returned
            to the client). `context` is the IETF problem-details extension
            slot — SDK consumers always read structured fields at
            `error.context.*`. See `WalletFailurePhase` in
            `services/platform/src/billing-wallet.ts` for the full enum.
          content:
            application/json:
              schema:
                type: object
                required:
                  - error
                properties:
                  error:
                    type: object
                    required:
                      - code
                      - message
                      - status
                      - request_id
                      - product
                      - docs_url
                    properties:
                      code:
                        type: string
                        enum:
                          - INTERNAL_ERROR
                      message:
                        type: string
                      status:
                        type: integer
                        enum:
                          - 500
                      request_id:
                        type: string
                      product:
                        type: string
                        enum:
                          - platform
                      docs_url:
                        type: string
                        format: uri
                      context:
                        type: object
                        description: |
                          IETF problem-details extension slot for per-error
                          structured fields.
                        properties:
                          phase:
                            type: string
                            description: |
                              Which handler phase failed. Stable strings safe for
                              clients to switch on; never includes the underlying
                              error message.
                            enum:
                              - config
                              - load_user_context
                              - recompute_grant
                              - read_wallet_projection
  /billing/checkout:
    post:
      operationId: createCheckoutSession
      summary: Create a Stripe Checkout subscription session
      description: |
        Creates a Stripe Checkout Session for a metered credit subscription
        ($0.05/credit, billed monthly based on consumption). Returns the hosted
        checkout URL inside a standard `{ data, meta }` envelope. No credits charged.
      x-credit-cost: 0
      x-motionworks-status: production
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                returnUrl:
                  type: string
                  format: uri
                  description: |
                    Optional return URL the user is redirected to after Stripe
                    Checkout success or cancel. When valid, replaces the
                    default `${firstAllowedOrigin}/?checkout=success` /
                    `${firstAllowedOrigin}/?checkout=cancel` URLs and appends
                    `?checkout=success` or `?checkout=cancel` (existing query
                    string preserved, no duplicate keys).

                    Validation rules (enforced server-side):
                      * Scheme MUST be `https:` (rejects `javascript:`,
                        `data:`, `file:`, `http:`).
                      * Origin MUST exactly match an entry in the worker's
                        `ALLOWED_ORIGINS` env binding (no wildcards, no
                        suffix matching).
                      * URL fragment (`#...`) is rejected.

                    Missing or invalid values fall back to the existing
                    hardcoded URLs (backward-compatible).
                  example: https://console.mworks.com/console/
      responses:
        '200':
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
                        format: uri
                        description: Stripe Checkout hosted page URL
                  meta:
                    type: object
                    properties:
                      request_id:
                        type: string
                      credits_used:
                        type: integer
                      credits_remaining:
                        type: integer
                      product:
                        type: string
                      version:
                        type: string
                      provenance:
                        type: object
        '400':
          description: Stripe API error (passthrough)
        '500':
          description: Internal error during checkout-session creation
        '503':
          description: Stripe is not configured (missing key or price id)
  /billing/webhook:
    post:
      operationId: stripeWebhook
      summary: Stripe webhook receiver (signature-verified)
      description: |
        Receives Stripe webhook events. Verifies the `Stripe-Signature` header
        with constant-time HMAC-SHA256 against the raw body, with a 5-minute
        freshness window. Returns `{ received: true }` on success — the body
        is consumed by Stripe (which only reads the HTTP status), so this
        endpoint is exempt from the standard `{ data, meta }` envelope.
      x-credit-cost: 0
      x-motionworks-status: production
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      parameters:
        - in: header
          name: stripe-signature
          required: true
          schema:
            type: string
          description: Stripe signature header (`t=<unix-ts>,v1=<hex-hmac>`)
      responses:
        '200':
          description: Event acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean
                    enum:
                      - true
        '400':
          description: Invalid signature, malformed signature, or unparseable JSON body
        '500':
          description: |
            Webhook processing failed (e.g. Supabase write error). Stripe should
            retry. Per ADR-20 §1, processing errors propagate as 500 rather than
            being swallowed so the multi-row idempotency gate is not violated.
        '503':
          description: Webhook secret is not configured
  /orders:
    post:
      operationId: createOrder
      summary: Create a bulk Placecast order (Profile or Select)
      description: |
        Submit a bulk Placecast order for one of two SKUs:

          - `profile` — Placecast Profile entitlements granted on N places.
            Unit price comes from `placecast_profile_call.usd_per_call`
            in the `PRICING_MANIFEST` (currently $1.00/place).
          - `select`  — Placecast Select event submissions for N places.
            Unit price comes from `placecast_select_submit.usd_per_call`
            (currently $100.00/place).

        The handler fulfills the order synchronously (v1):
          1. Insert one `orders` row (`status='processing'`).
          2. Insert one `places_placecast_overlay` row per place_id
             granting `has_placecast=true` on the named places.
          3. Insert one `order_line_items` row (billing log only —
             Stripe push is deferred to the reconciliation worker).
          4. Insert `audit_log` rows: `placecast_order_fulfilled`
             always, plus `set_membership_refresh` when
             `source_set_id` is supplied (consumed by the
             reconciler in a follow-up PR).
          5. Patch the order to `status='fulfilled'` with
             `fulfilled_at = NOW()`.

        See `app-mworks-com#159` + `services/platform/src/orders.ts`.
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - sku
                - place_ids
              additionalProperties: false
              properties:
                sku:
                  type: string
                  enum:
                    - profile
                    - select
                place_ids:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type: string
                    minLength: 1
                    maxLength: 128
                source_set_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: |
                    Optional reference to the saved set the order was
                    derived from. When supplied, the handler emits a
                    `set_membership_refresh` audit event so the
                    reconciler can rebuild dependent membership views.
      parameters:
        - in: query
          name: org_id
          required: false
          schema:
            type: string
            format: uuid
          description: |
            Optional disambiguation for users with multiple org
            memberships. Mirrors the `/v2/account/keys` pattern. When
            unspecified and the user has more than one org, the order's
            `org_id` column is left NULL (user_id is always set).
      responses:
        '201':
          description: |
            Order created and fulfilled. Returns the full order row in
            the standard `{ data, meta }` envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required:
                      - id
                      - sku
                      - place_ids
                      - count
                      - unit_price
                      - total
                      - status
                      - created_at
                    properties:
                      id:
                        type: string
                        format: uuid
                      sku:
                        type: string
                        enum:
                          - profile
                          - select
                      place_ids:
                        type: array
                        items:
                          type: string
                      count:
                        type: integer
                      unit_price:
                        type: number
                      total:
                        type: number
                      source_set_id:
                        type: string
                        format: uuid
                        nullable: true
                      status:
                        type: string
                        enum:
                          - processing
                          - fulfilled
                      created_at:
                        type: string
                        format: date-time
                      fulfilled_at:
                        type: string
                        format: date-time
                        nullable: true
                  meta:
                    type: object
        '400':
          description: Validation error (bad sku, empty place_ids, malformed source_set_id, etc.)
        '401':
          description: Missing or invalid Supabase JWT
        '500':
          description: Order create or fulfillment failed; check error body for `order_id` if any.
