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
    FeatureKey:
      type: string
      description: |
        Canonical product-level feature key from the `@mworks/billing`
        taxonomy (ADR-029). The full set is published as a
        machine-readable cross-repo contract at
        `docs/contracts/feature-keys.json` (ADR-029 Phase 4, #355) and
        kept in lockstep with the Supabase `feature_catalog` edit
        source-of-truth via `scripts/feature-keys-contract-codegen.mjs`.
        Keys are stable — once shipped, they are never renamed.
      enum:
        - markets.reference.geography_tiles
        - oohdisplays.displays.face_point_tiles
        - pathcast.paths.path_line_tiles
        - placecast.places.autocomplete
        - placecast.places.poi_place_tiles
        - placecast.places.poi_retrieval
        - placecast.places.search
        - placecast.profiles.building_dca_tiles
        - placecast.profiles.profile_retrieval
        - popcast.anytime.segment_occ_tiles
        - popcast.at_home.segment_pop_tiles
        - popcast.segments.segment_search
        - viewcast.profiles.custom_inventory
        - viewcast.profiles.inventory_tiles
        - viewcast.profiles.profile_retrieval
    PlatformError:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - status
            - request_id
          properties:
            code:
              type: string
              description: |
                Stable machine-readable error code (e.g. `UNAUTHORIZED`,
                `PORTAL_NOT_AVAILABLE`, `SUBSCRIPTION_NOT_FOUND`).
            message:
              type: string
              description: Customer-safe message; never `String(e)`.
            status:
              type: integer
              minimum: 400
              maximum: 599
            request_id:
              type: string
            product:
              type: string
              enum:
                - platform
            docs_url:
              type: string
              format: uri
            context:
              type: object
              additionalProperties: true
              description: |
                IETF problem-details extension slot for per-error
                structured fields (e.g. `phase`, `plan`, `reason`,
                `retry_after_seconds`).
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
                      - rate_limits
                    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'
                      rate_limits:
                        type: object
                        description: |
                          Per-tier request-rate limits (ADR-029 §5). Unit
                          is requests/min. Customer-facing tier names
                          (guest/free/payg). Tiles + autocomplete are
                          unrestricted at every tier; an explicit feature
                          grant promotes a bought feature to unlimited.
                        required:
                          - unit
                          - by_tier
                          - unrestricted_features
                          - notes
                        properties:
                          unit:
                            type: string
                            enum:
                              - requests_per_minute
                          by_tier:
                            type: object
                            required:
                              - guest
                              - free
                              - payg
                            properties:
                              guest:
                                type: object
                                required:
                                  - per_min
                                properties:
                                  per_min:
                                    type: integer
                                    example: 2
                                  burst:
                                    type: integer
                                    example: 5
                              free:
                                type: object
                                required:
                                  - per_min
                                properties:
                                  per_min:
                                    type: integer
                                    example: 12
                                  burst:
                                    type: integer
                              payg:
                                type: object
                                required:
                                  - per_min
                                properties:
                                  per_min:
                                    type: integer
                                    example: 60
                                  burst:
                                    type: integer
                          unrestricted_features:
                            type: array
                            items:
                              type: string
                            description: Feature keys with no rate ceiling at any tier.
                          notes:
                            type: string
                  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/entitlements:
    get:
      operationId: getEntitlements
      summary: Get effective product-level feature entitlements for the caller
      description: |
        Returns the calling principal's effective product-level feature
        entitlements (ADR-029, Accepted 2026-05-30; observe-mode shipped
        in PR #290).

        For each of the 12 canonical feature keys in the `@mworks/billing`
        taxonomy, the response carries an `access` verdict, a `source`
        tag indicating where the verdict came from, and the caller's
        effective `rate` limit for that feature:
          - `source: 'grant'` — the caller's org has an explicit row in
            `public.org_feature_grants` for this feature. `access` is
            always `'allow'`.
          - `source: 'default'` — no explicit grant; the verdict is the
            user-class default-grant matrix entry. Manual-grant-only
            features (today: `placecast.profiles.building_dca_tiles`,
            `popcast.anytime.segment_occ_tiles`) carry `access: 'deny'`
            here for every class — they only become licensed via a
            grant row.

        Three auth modes, all 0 credits:
          - `X-API-Key: mw_<64-hex>` → user_class `'payg'`, org via
            Supabase `validate_key` RPC.
          - `Authorization: Bearer <jwt>` → user_class `'authenticated'`,
            org via `org_members` (lazy `provision_default_org` repair
            for OAuth signups, same fallback as `/v2/billing/wallet` and
            `/v2/account/keys`).
          - Neither → user_class `'anon'`, no org, default-matrix-only.

        This endpoint NEVER enforces and NEVER returns 403. It is a
        read-only introspection surface for callers (Console UI, SDKs)
        to learn what the enforcement layer WOULD allow. The enforcement
        layer ships in a follow-up PR.

        Fail-soft posture on the explicit-grants read: if the
        `org_feature_grants` lookup fails (Supabase outage), the
        response still returns 200 with default-matrix verdicts AND
        sets `meta.grants_unavailable: true`. The response stays a
        strict subset of true entitlements — defaults grant LESS than
        grants ever can, so degrading to defaults is the safe direction.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - apiKey: []
        - bearerAuth: []
        - {}
      responses:
        '200':
          description: Entitlement composition for the calling principal.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - user_class
                      - org_id
                      - features
                    properties:
                      user_class:
                        type: string
                        enum:
                          - anon
                          - authenticated
                          - payg
                        description: |
                          Auth-derived caller class:
                            - `anon` — no auth headers.
                            - `authenticated` — JWT bearer.
                            - `payg` — X-API-Key.
                      org_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: |
                          Resolved organization id, or `null` when no
                          org is associated with the caller (anon path
                          always; JWT path only when lazy provisioning
                          itself failed — though that case surfaces as
                          500 `INTERNAL_ERROR` `context.phase=load_org`
                          before reaching the 200 path).
                      features:
                        type: array
                        description: |
                          One entry per canonical feature key. Exactly
                          12 entries today (matches the size of
                          `FEATURE_TAXONOMY` in `@mworks/billing`).
                        items:
                          type: object
                          required:
                            - key
                            - family
                            - product
                            - feature
                            - label
                            - access
                            - source
                            - rate
                          properties:
                            key:
                              allOf:
                                - $ref: '#/components/schemas/FeatureKey'
                              description: |
                                Stable `family.product.feature` key. Never
                                renamed once shipped. Drawn from the
                                published cross-repo contract at
                                `docs/contracts/feature-keys.json` —
                                see `FeatureKey` for the enum.
                            family:
                              type: string
                              enum:
                                - placecast
                                - pathcast
                                - popcast
                                - viewcast
                            product:
                              type: string
                            feature:
                              type: string
                            label:
                              type: string
                              description: Short human label for log lines / Console UI.
                            access:
                              type: string
                              enum:
                                - allow
                                - allow_limited
                                - deny
                              description: |
                                `allow` and `allow_limited` both license
                                the feature; the distinction is the
                                anon-tier abuse fences in the router
                                (per `feature-access.ts`). `deny` means
                                the feature is not licensed under the
                                composed verdict.
                            source:
                              type: string
                              enum:
                                - grant
                                - default
                              description: |
                                `grant` when an explicit
                                `org_feature_grants` row was found for
                                this org+key (overrides default deny).
                                `default` when the verdict came from
                                the user-class default-grant matrix.
                            rate:
                              description: |
                                The caller's effective request-rate limit
                                for this feature (ADR-029 §5, AC9). Either
                                the string `unlimited` (tiles, autocomplete,
                                or any explicitly-granted feature) or an
                                object with `per_min` (and optional `burst`).
                                Derived from the same resolver the router
                                enforces with.
                              oneOf:
                                - type: string
                                  enum:
                                    - unlimited
                                - type: object
                                  required:
                                    - per_min
                                  properties:
                                    per_min:
                                      type: integer
                                    burst:
                                      type: integer
                  meta:
                    type: object
                    required:
                      - request_id
                      - credits_used
                      - credits_remaining
                      - product
                      - provenance
                    properties:
                      request_id:
                        type: string
                      credits_used:
                        type: integer
                        enum:
                          - 0
                      credits_remaining:
                        type: integer
                      product:
                        type: string
                        enum:
                          - platform
                      version:
                        type: string
                      provenance:
                        type: object
                      grants_unavailable:
                        type: boolean
                        description: |
                          Set to `true` when the explicit `org_feature_grants`
                          read failed (Supabase outage) and the response
                          carries default-matrix verdicts only. Absent
                          (or `false`) when the grants read succeeded
                          OR when the caller has no org (anon path).
        '401':
          description: |
            Invalid/expired JWT (UNAUTHORIZED) or invalid/revoked
            X-API-Key (INVALID_API_KEY). 401 is intentionally
            phase-free.
          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
                          - INVALID_API_KEY
                      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 Supabase service credentials
            are unset (`config`) or the JWT path's org_members lookup
            fails (`load_org`). The grants-read failure path does NOT
            500 — it returns 200 with `meta.grants_unavailable: true`.
          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
                        properties:
                          phase:
                            type: string
                            enum:
                              - config
                              - load_org
  /billing/reconciliation-status:
    get:
      operationId: getReconciliationStatus
      summary: Get billing reconciliation transparency for the current period
      description: |
        Returns billing reconciliation transparency data for the caller's
        org and current billing period (ADR-26 PR-4a). Powers the
        `console.mworks.com` `/console/billing/` "Billed through:" line
        and the reconciling/unbilled count pills.

        Fields:
          - `billed_through_utc` — ISO date (`YYYY-MM-DD`) of the most
            recent fully-rolled-up day in `public.usage_daily_summary` for
            this org. `null` when the materialized view has no rows for
            this org yet (e.g. brand-new org pre-first-MV-refresh, or an
            org with zero usage).
          - `reconciling_count` — number of `public.billing_meter_outbox`
            rows in `status='pending'` for this org within the current
            billing period. Steady-state hits are transient (a row sits
            in 'pending' for ~seconds while the dispatcher reports to
            Stripe). A non-zero count for >1 minute suggests dispatcher
            backlog.
          - `unbilled_count` — same shape, `status='abandoned'`. These
            are billing pipeline drops: the request was 2xx-served but the
            meter event was not reported to Stripe. ADR-20 W3 reconciliation
            should drive this to zero.

        Billing period boundary: the caller's `organizations.billing_period_start`
        / `billing_period_end` (UTC timestamps maintained by the Stripe
        webhook handlers). Same definition as the `billing_period_*` fields
        returned by `GET /v2/organizations/me`.

        Auth REQUIRED (Supabase JWT). 0 credits charged.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Reconciliation status snapshot.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - billed_through_utc
                      - reconciling_count
                      - unbilled_count
                    properties:
                      billed_through_utc:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-05-28'
                        description: |
                          ISO date (UTC) of the most recently fully-rolled-up
                          day in `usage_daily_summary`. `null` when no
                          summary rows exist for the org yet.
                      reconciling_count:
                        type: integer
                        minimum: 0
                        description: |
                          Number of `billing_meter_outbox` rows in
                          `status='pending'` for the org within the current
                          billing period.
                      unbilled_count:
                        type: integer
                        minimum: 0
                        description: |
                          Number of `billing_meter_outbox` rows in
                          `status='abandoned'` for the org within the current
                          billing period.
                  meta:
                    type: object
        '401':
          description: |
            Missing, malformed, or expired bearer token.
          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
        '404':
          description: |
            No organization is associated with this user (lazy
            `provision_default_org` ran but the re-read still returned
            nothing — RLS misconfig or race against a delete).
          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:
                          - ORG_NOT_FOUND
                      message:
                        type: string
                      status:
                        type: integer
                        enum:
                          - 404
                      request_id:
                        type: string
                      product:
                        type: string
                        enum:
                          - platform
                      docs_url:
                        type: string
                        format: uri
        '500':
          description: |
            Internal error. Emitted when Supabase service credentials are
            unset (`config`), the org membership lookup fails
            (`load_org`), the `usage_daily_summary` read fails
            (`read_summary`), or the `billing_meter_outbox` count read
            fails (`count_outbox`). The `context.phase` field tags which
            handler step failed so production 500s can be triaged without
            log access.
          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
                            enum:
                              - config
                              - load_org
                              - read_summary
                              - count_outbox
  /usage/daily:
    get:
      operationId: getUsageDaily
      summary: Get the caller's org daily usage rollup (windowed)
      description: |
        Returns a daily usage rollup for the caller's organization,
        windowed by `?from=YYYY-MM-DD&to=YYYY-MM-DD` (UTC calendar days,
        inclusive). Source: the `public.usage_daily_summary` materialized
        view (ADR-26 §8). Powers `console.mworks.com`'s usage chart and
        per-endpoint breakdown.

        Auth REQUIRED (Supabase JWT). 0 credits charged.

        Defaults: when both bounds are omitted, the handler returns the
        last 30 days ending today (UTC) — `to = today`, `from = today − 29`.
        When only one bound is supplied the other is derived from it the
        same way.

        Cap: the window is capped at 90 days. The MV has a 90-day
        retention ceiling (`usage-logs-drop-old-partitions` cron, ADR-26
        §6), so a wider request has no data behind it.

        Empty states (all return 200, NOT errors):
          - Caller has no `org_members` row (e.g. brand-new OAuth user
            pre-lazy-provision): `org_id: null`, empty `days`, zero
            totals.
          - Org has membership but no MV rows in the window: `org_id`
            populated, empty `days`, zero totals.

        Each day also carries a `by_key` array of per-API-key
        attribution (api#293, MV migration 0023): one entry per
        api_key_id observed in `usage_logs` for that org/day, sorted
        descending by `calls`. Days with only JWT / console traffic
        carry an empty array. Keys deleted out-of-band surface with a
        synthetic label (`Deleted key (…<last4>)`) so historical usage
        is never silently dropped. JWT/console traffic contributes to
        `total_calls`/`total_credits`/`by_endpoint` but not to `by_key`.

        Vector-tile usage (api#320 Gap 2) surfaces as a separate
        `tile_credits` / `tile_events` dimension per day plus a matching
        pair on `totals`. Source: `public.billing_meter_outbox` via the
        `org_tile_usage_daily` RPC (migration 0028) — vector tiles do
        NOT write `usage_logs` rows (2026-05-31 TEAM.md review +
        ADR-026), so the MV alone is blind to tile usage. The tile
        dimension is **additive** and never folded into
        `total_credits` / `by_endpoint` / `by_key`, which stay byte-
        stable for existing consumers. `tile_events` counts billing-
        outbox dispatches (one per call under `TILE_METER_MODE='direct'`,
        one per 1000-tile boundary cross under `'accumulate'`), NOT
        raw tile fetches. Days carrying tile traffic but zero
        `usage_logs`-scoped activity (a tile-only org-day) appear in
        `days` with the `usage_logs`-scoped fields zeroed and the tile
        fields populated.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      parameters:
        - in: query
          name: from
          required: false
          schema:
            type: string
            format: date
            pattern: ^\d{4}-\d{2}-\d{2}$
          description: |
            Inclusive lower bound of the UTC date window. Defaults to
            `to − 29` days when omitted (yielding a 30-day window).
          example: '2026-05-01'
        - in: query
          name: to
          required: false
          schema:
            type: string
            format: date
            pattern: ^\d{4}-\d{2}-\d{2}$
          description: |
            Inclusive upper bound of the UTC date window. Defaults to
            today (UTC) when omitted.
          example: '2026-05-30'
      responses:
        '200':
          description: Daily usage rollup.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - org_id
                      - from
                      - to
                      - days
                      - totals
                    properties:
                      org_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: |
                          The caller's organization id. `null` when the
                          caller has no `org_members` row yet (e.g.
                          brand-new OAuth user pre-lazy-provision).
                      from:
                        type: string
                        format: date
                        example: '2026-05-01'
                      to:
                        type: string
                        format: date
                        example: '2026-05-30'
                      days:
                        type: array
                        description: |
                          Per-day rollups ordered ascending by `day`.
                          Empty when the window has no MV rows for the
                          org.
                        items:
                          type: object
                          required:
                            - day
                            - total_calls
                            - total_credits
                            - by_endpoint
                            - by_key
                            - tile_credits
                            - tile_events
                          properties:
                            day:
                              type: string
                              format: date
                              example: '2026-05-01'
                            total_calls:
                              type: integer
                              minimum: 0
                            total_credits:
                              type: integer
                              minimum: 0
                            by_endpoint:
                              type: object
                              description: |
                                Per-endpoint breakdown of the day's
                                usage. Keys are the canonical
                                "method path" strings as recorded in
                                `usage_logs.endpoint` (e.g.
                                `GET /v2/placecast/profiles/:id`).
                              additionalProperties:
                                type: object
                                required:
                                  - credits
                                  - calls
                                properties:
                                  credits:
                                    type: integer
                                    minimum: 0
                                  calls:
                                    type: integer
                                    minimum: 0
                            by_key:
                              type: array
                              description: |
                                Per-API-key attribution for the day,
                                sorted descending by `calls`. Empty
                                when the day saw only JWT / console
                                traffic. `label` is the human name
                                from `api_keys.name`; out-of-band
                                deletes surface with a synthetic
                                `Deleted key (…<last4>)` so usage is
                                never silently dropped.
                              items:
                                type: object
                                required:
                                  - api_key_id
                                  - label
                                  - calls
                                  - credits
                                properties:
                                  api_key_id:
                                    type: string
                                    format: uuid
                                  label:
                                    type: string
                                  calls:
                                    type: integer
                                    minimum: 0
                                  credits:
                                    type: integer
                                    minimum: 0
                            tile_credits:
                              type: integer
                              minimum: 0
                              description: |
                                api#320 Gap 2: aggregate credits billed
                                for vector-tile traffic on this day,
                                from `public.billing_meter_outbox`
                                (NOT `usage_logs`). 0 when the day has
                                no tile billing rows. Additive — not
                                folded into `total_credits` /
                                `by_endpoint` / `by_key`.
                            tile_events:
                              type: integer
                              minimum: 0
                              description: |
                                api#320 Gap 2: count of billing-outbox
                                rows for vector-tile traffic on this
                                day. One per call under
                                `TILE_METER_MODE='direct'`; one per
                                1000-tile boundary cross under
                                `'accumulate'`. NOT a count of raw
                                tile fetches.
                      totals:
                        type: object
                        required:
                          - total_calls
                          - total_credits
                          - tile_credits
                          - tile_events
                        description: |
                          Sums across the returned window. All four
                          fields are 0 when `days` is empty.
                        properties:
                          total_calls:
                            type: integer
                            minimum: 0
                          total_credits:
                            type: integer
                            minimum: 0
                          tile_credits:
                            type: integer
                            minimum: 0
                            description: |
                              Sum of per-day `tile_credits` over the
                              returned window (api#320 Gap 2).
                          tile_events:
                            type: integer
                            minimum: 0
                            description: |
                              Sum of per-day `tile_events` over the
                              returned window (api#320 Gap 2).
                  meta:
                    type: object
        '400':
          description: |
            Validation error. Possible codes:
              - `INVALID_DATE` — `from` or `to` is not a valid
                YYYY-MM-DD UTC date. `error.context.parameter` carries
                which one.
              - `INVALID_RANGE` — `from` is later than `to`.
              - `RANGE_TOO_WIDE` — window exceeds 90 days.
                `error.context.max_days` and `error.context.requested_days`
                carry the details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '401':
          description: Missing, malformed, or expired bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '500':
          description: |
            Internal error. Emitted when Supabase service credentials are
            unset (`config`), the `org_members` lookup fails (`load_org`),
            or the `usage_daily_summary` read fails (`read_summary`). The
            `context.phase` field tags which handler step failed so
            production 500s can be triaged without log access.
          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
                            enum:
                              - config
                              - load_org
                              - read_summary
  /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
  /organizations/me:
    get:
      operationId: getOrganizationMe
      summary: Get the authenticated user's organization
      description: |
        Returns the caller's organization in a single round-trip, source-of-truth
        read for the `app2.mworks.com/console/organization/` page (Track A).

        Includes the plan label, sandbox flag, billing source, Stripe IDs,
        credit counters, and billing-period bookends. Auth REQUIRED (Supabase
        JWT). 0 credits charged.

        Plan label source: `organizations.plan` text column. NEVER joined
        against `plans` (the table is structurally drifted post-tier-retirement;
        a `plans!inner(name)` join would render "Sandbox" for paying
        customers). See `reviews/2026-05-27-track-a-org-page-scoping/_cto-synthesis.md`
        §5 for the gate.

        `billing_source` derivation:
          * `metadata.billing_source === 'direct'` → `"direct"` (Motionworks
            internal, invoiced offline)
          * `metadata.billing_source === 'stripe'` → `"stripe"` (explicit)
          * else, `stripe_customer_id IS NOT NULL` → `"stripe"`
          * else → `null`
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Organization snapshot.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - id
                      - name
                      - plan
                      - sandbox
                      - billing_source
                      - stripe_customer_id
                      - stripe_subscription_id
                      - credits_remaining
                      - credits_allocated
                      - billing_period_start
                      - billing_period_end
                      - created_at
                    properties:
                      id:
                        type: string
                        format: uuid
                      name:
                        type: string
                      plan:
                        type: string
                        description: |
                          Customer-visible plan label. Source of truth:
                          `organizations.plan` text column. Today's enum is
                          `free | paid | custom` but the schema deliberately
                          accepts any string so a future plan rename does
                          not break consumers immediately.
                      sandbox:
                        type: boolean
                      billing_source:
                        type: string
                        enum:
                          - stripe
                          - direct
                        nullable: true
                      stripe_customer_id:
                        type: string
                        nullable: true
                      stripe_subscription_id:
                        type: string
                        nullable: true
                      credits_remaining:
                        type: integer
                        minimum: 0
                      credits_allocated:
                        type: integer
                        minimum: 0
                      billing_period_start:
                        type: string
                        format: date-time
                      billing_period_end:
                        type: string
                        format: date-time
                      created_at:
                        type: string
                        format: date-time
                  meta:
                    type: object
        '401':
          description: Missing, malformed, or expired bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '404':
          description: |
            The authenticated user has no org_members row. Should not happen
            under normal traffic (lazy default-org provisioning fires on
            `/v2/account/keys` and `/v2/billing/wallet` first-touch) but
            defended here for completeness.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '500':
          description: |
            Supabase outage or config misconfiguration. The `error.context.phase`
            field tags which handler step failed (`config` or `fetch_org`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
  /billing/portal:
    post:
      operationId: createBillingPortalSession
      summary: Create a Stripe Customer Portal session
      description: |
        Creates a Stripe Customer Portal session for the caller's
        organization and returns the redirect URL. The portal owns
        payment-method updates, invoice history, and subscription
        cancellation; we do NOT replicate that surface in-app.

        Auth REQUIRED (Supabase JWT). 0 credits charged.

        Rate-limited to 5 requests / minute / user. Fails CLOSED in
        production when the `RATE_LIMIT` KV binding is unbound.

        Allowed plans: `paid` only. `free` (no subscription to manage)
        and `custom` (direct-billed; the portal would render nothing
        meaningful) both return 403 `PORTAL_NOT_AVAILABLE`. The orphan
        state `plan='paid' + stripe_customer_id=null` also returns 403.

        Audit trail: every successful call logs `request_id`, `org_id`,
        `user_id`, `return_url`, and the request `Origin` to the
        worker's stderr for forensics.
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                return_url:
                  type: string
                  format: uri
                  description: |
                    Optional URL the user is redirected to from the
                    Stripe Customer Portal. Allowlist-validated via the
                    same `validateReturnUrl` helper used by
                    `/v2/billing/credits` (https-only, no fragment,
                    exact-origin allowlist from `env.ALLOWED_ORIGINS`).

                    Defaults to `https://app2.mworks.com/console/organization/`
                    when omitted.
                  example: https://app2.mworks.com/console/organization/
      responses:
        '200':
          description: Portal session created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - url
                      - expires_at
                    properties:
                      url:
                        type: string
                        format: uri
                        description: The Stripe Customer Portal session URL.
                      expires_at:
                        type: string
                        format: date-time
                        description: |
                          Synthesized as `now + 1h` per Stripe's
                          documented portal-session TTL. The UI may
                          cache the URL up to this timestamp.
                  meta:
                    type: object
        '400':
          description: Bad `return_url` (rejected by the allowlist validator).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '401':
          description: Missing, malformed, or expired bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '403':
          description: |
            `PORTAL_NOT_AVAILABLE` — the Customer Portal is not the right
            surface for this caller. `error.context.plan` carries the
            current plan; `error.context.reason` may carry
            `missing_stripe_customer` for the orphan state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '404':
          description: No organization is associated with this user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '429':
          description: |
            Per-user rate limit exceeded (5/min/user). `Retry-After`
            header carries the suggested wait in seconds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '502':
          description: |
            Stripe returned a 4xx (the customer record is invalid or
            the request was malformed). Inspect the worker logs for
            the upstream detail; the customer-facing message is
            deliberately generic.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '503':
          description: |
            Stripe returned a 5xx or the request to Stripe threw.
            `Retry-After: 30` is set; UI should show a transient
            failure affordance and retry on next mount.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
  /billing/subscription:
    get:
      operationId: getBillingSubscription
      summary: Read the caller's live Stripe subscription state
      description: |
        Returns the live subscription record for the caller's org from
        Stripe. Powers the "Pay-As-You-Go" / "Pay-As-You-Go (cancelling)"
        badge + the billing-period-ends date on
        `app2.mworks.com/console/organization/`.

        Auth REQUIRED (Supabase JWT). 0 credits charged.

        Caches per-org in `ORG_CACHE` for 30 seconds when the binding is
        present (the page hits this twice per render today). Cache
        failures fall through to the live Stripe read.

        Returns 404 `SUBSCRIPTION_NOT_FOUND` for plan=free, plan=custom,
        when `stripe_subscription_id` is null, OR when Stripe itself
        reports the subscription as missing (the org row is stale —
        the page surfaces a recovery affordance).
      tags:
        - billing
      x-credit-cost: 0
      x-motionworks-status: production
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Live subscription state.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                  - meta
                properties:
                  data:
                    type: object
                    required:
                      - status
                      - current_period_end
                      - cancel_at_period_end
                      - payment_method
                    properties:
                      status:
                        type: string
                        description: |
                          Stripe subscription status. Typical values
                          include `active`, `past_due`, `canceled`,
                          `incomplete`, `trialing`. The schema accepts
                          any string so future Stripe additions do not
                          break consumers immediately.
                      current_period_end:
                        type: string
                        format: date-time
                      cancel_at_period_end:
                        type: boolean
                      payment_method:
                        type: object
                        nullable: true
                        required:
                          - brand
                          - last4
                        properties:
                          brand:
                            type: string
                            description: e.g. `visa`, `mastercard`, `amex`
                          last4:
                            type: string
                            minLength: 4
                            maxLength: 4
                  meta:
                    type: object
        '401':
          description: Missing, malformed, or expired bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '404':
          description: |
            `SUBSCRIPTION_NOT_FOUND` — no active Stripe subscription
            is associated with this organization. Fires when plan is
            free or custom, `stripe_subscription_id` is null, or
            Stripe returned 404 for the subscription id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '502':
          description: Stripe returned a 4xx or a malformed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
        '503':
          description: |
            Stripe returned a 5xx or the request to Stripe threw.
            `Retry-After: 30` is set.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformError'
  /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.
