openapi: "3.1.0"
# #2540: Registered-app order payment reads belong to the kernel pay contract.
info:
  title: imajin pay
  version: "2.0.0"
  description: |
    Payments, balances, payment-rail (provider) account onboarding, .fair multi-party
    settlement, escrow, and webhooks. Rail-specific operations have rail-generic
    equivalents keyed by a provider (Stripe is one value). Runs as a kernel service
    at /{service}/api/... on a shared node domain.
    Outbound app callbacks carry detached Ed25519 signatures verified with
    @ima-jin/auth verifyKernelWebhook and /auth/.well-known/kernel-signing-key.
    Headers are x-kernel-audience (recipient app DID), x-kernel-kid,
    x-kernel-timestamp (Unix seconds), x-kernel-nonce,
    x-kernel-signature (hex). Canonical signed content is imajin-webhook-v1 followed
    by audience, timestamp, nonce, uppercase method, pathname+query, SHA-256 hex of raw body,
    each separated by a newline with no trailing newline. Defaults accept ±300s
    and reject nonce reuse or an audience different from the receiver's own
    app DID (AUDIENCE_MISMATCH). Failure codes and rollout are documented in
    docs/security/kernel-webhooks.md. Stripe inbound authentication is unchanged.
servers:
  - url: https://{node}.imajin.ai/{service}
    description: production node
    variables:
      node:
        default: jin
      service:
        default: pay
  - url: http://localhost:3000/{service}
    description: local development
    variables:
      service:
        default: pay

tags:
  - name: pay
    description: imajin pay service

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: imajin_session
    bearerAuth:
      type: http
      scheme: bearer
      description: "Bearer token (internal service-to-service or user API token)"
    appServiceToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: "Registered-app service token (typ app-service+jwt) from POST /auth/api/apps/token/service, carrying the route's operator-approved per-app scope (pay:read, pay:settle, pay:refund, pay:charge-pledges or pay:setup-intent); active app and live approval checked on every call"
    railWebhookSignature:
      type: apiKey
      in: header
      name: stripe-signature
      description: |
        Rail webhook signature, verified by the rail's own adapter — the `{provider}`
        path segment of the rail-generic webhook operations selects the verifier.
        Today `stripe` is the only registered rail, whose signature header is
        `stripe-signature` (HMAC-SHA256); a new rail registers its own header with
        its adapter. #2177.
    stripeWebhook:
      type: apiKey
      in: header
      name: stripe-signature
      description: "Legacy alias of `railWebhookSignature` for the `stripe` provider (Stripe webhook signature verification, HMAC-SHA256)"
  parameters:
    provider:
      name: provider
      in: path
      required: true
      description: "Payment rail (provider) key — `stripe` is one value of the enum (#2177)"
      schema: { $ref: "#/components/schemas/WebhookProvider" }
  schemas:
    SetupIntentBinding:
      type: object
      required: [pledgeId, eventId, buyerDid, sellerDid, amount, currency]
      description: |
        Values MUST come from the calling app's authenticated buyer and stored
        pledge/event records, not buyer-submitted amounts or identities.
        Unknown fields, including metadata and appDid, are ignored.
      properties:
        pledgeId: { type: string, minLength: 1, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" }
        eventId: { type: string, minLength: 1, maxLength: 200, pattern: "^[A-Za-z0-9_-]+$" }
        buyerDid: { type: string, maxLength: 500, pattern: "^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$" }
        sellerDid: { type: string, maxLength: 500, pattern: "^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$" }
        amount: { type: integer, minimum: 1, maximum: 99999900, description: "Pledge amount in minor units; bound as canonical decimal metadata, no charge yet" }
        currency: { type: string, pattern: "^[A-Za-z]{3}$", description: "Three-letter currency; canonicalized to uppercase" }
    SetupIntentVerifyRequest:
      allOf:
        - $ref: "#/components/schemas/SetupIntentBinding"
        - type: object
          required: [setupIntentId, customerId]
          properties:
            setupIntentId: { type: string, maxLength: 200, pattern: "^seti_[A-Za-z0-9]+$" }
            customerId: { type: string, maxLength: 200, pattern: "^cus_[A-Za-z0-9]+$" }
    SetupIntentVerified:
      type: object
      additionalProperties: false
      required: [verified, setupIntentId, customerId, paymentMethodId, pledgeId, eventId, buyerDid, sellerDid, amount, currency]
      properties:
        verified: { type: boolean, const: true }
        setupIntentId: { type: string }
        customerId: { type: string }
        paymentMethodId: { type: string, description: "Retrieved from the succeeded intent, never accepted from the buyer" }
        pledgeId: { type: string }
        eventId: { type: string }
        buyerDid: { type: string }
        sellerDid: { type: string }
        amount: { type: integer }
        currency: { type: string }
    SetupIntentError:
      type: object
      additionalProperties: false
      required: [error, code]
      properties:
        error: { type: string }
        code:
          type: string
          enum: [setup_intent_invalid_request, setup_intent_binding_mismatch, setup_intent_not_succeeded, setup_intent_resource_not_found, SETUP_APP_UNAUTHORIZED, SELLER_NO_CARD_RAIL, SETUP_INTENT_UNAVAILABLE]
    PaymentProvider:
      type: string
      enum: [stripe, solana]
      description: |
        Payment rail that processed a charge or holds an escrow. Stripe is ONE value
        of this enum — a new rail is a new value here (and in `WebhookProvider` when
        it has a webhook), never a new Stripe-style field or operation (#2177,
        parent #2173).
    WebhookProvider:
      type: string
      enum: [stripe]
      description: "Rails with a webhook — the `{provider}` path segment of the rail-generic operations (#2177)."
    Balance:
      type: object
      description: |
        #2016: balances is an explicit array of one entry per wallet unit —
        MJN (receipt-backed, fiat-minted, withdrawable) and MJNx (emitted
        platform unit, in-platform only, never withdrawable, never silently
        convertible to MJN). `total`/`currency` are kept for backward
        compatibility with the events/coffee/market/learn app proxies.
      properties:
        did: { type: string }
        balances:
          type: array
          items:
            type: object
            properties:
              unit: { type: string, enum: [MJN, MJNx] }
              amount: { type: number }
              withdrawable: { type: boolean, description: "true only for the MJN entry" }
        total: { type: number, description: "MJN amount + MJNx amount" }
        currency: { type: string, default: USD, description: "The MJN row's fiat reference currency" }
        updatedAt: { type: string, format: date-time }
    Transaction:
      type: object
      properties:
        id: { type: string }
        fromDid: { type: string }
        toDid: { type: string }
        amount: { type: number }
        currency: { type: string }
        service: { type: string }
        type: { type: string }
        status: { type: string }
        metadata: { type: object }
        createdAt: { type: string, format: date-time }
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
    PaymentRequest:
      type: object
      description: |
        #2206/#2207: an invoice / money request as a first-class receivable
        on the business DID. `fair_manifest` is always non-null — every
        payment_request carries a .fair manifest (the simple case is a
        single-payee manifest, built via `buildFairManifest` when the
        caller supplies none).
      properties:
        id: { type: string }
        kind: { type: string, enum: [invoice, request] }
        issuerDid: { type: string }
        payeeAccount: { type: string }
        recipientDid: { type: [string, "null"] }
        recipientStubId: { type: [string, "null"] }
        paidByDid: { type: [string, "null"], description: "#2656 — the DID the payer chose to pay as (own DID, or an org/business they control as owner/admin). The invoice stays addressed to recipientDid; settlement, the .fair buyer, the attestation and the receipt name this DID. null = no choice, settles as the recipient." }
        payHandle: { type: string, description: "Opaque handle for the unauthenticated GET /api/payment-requests/by-handle/{handle} read (#2210)" }
        lineItems:
          type: array
          items:
            type: object
            required: [name, amount, quantity]
            properties:
              name: { type: string }
              description: { type: string }
              amount: { type: integer, description: "Unit price in minor units" }
              quantity: { type: integer }
        currency: { type: string }
        totalAmount: { type: integer, description: "Minor units" }
        fairManifest: { type: object, description: ".fair manifest — never null" }
        dueAt:
          type: [string, "null"]
          format: date-time
          description: >-
            Due date, a CALENDAR date (#2651) stored as UTC midnight of the day the issuer picked
            (e.g. `2026-10-06T00:00:00.000Z` is due on 2026-10-06). Read the date component in UTC —
            never convert to the viewer's local zone, which shows the previous day west of UTC.
        allowOnPlatform: { type: boolean }
        status: { type: string, enum: [issued, paid, settled_manual, void] }
        settlementRef:
          type: [object, "null"]
          description: "Rail checkout session id (e.g. Stripe) | mjnx tx | manual { method, note, asserted_by, settled_at }"
        contentHash: { type: string, description: "Attestations bind this, never bytes" }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    PaymentRequestPublicView:
      type: object
      description: |
        #2210 — the minimum-necessary, unauthenticated view returned by
        GET /api/payment-requests/by-handle/{handle}. No issuer DID, no
        recipient PII, no fair_manifest/settlement_ref/content_hash.
      properties:
        kind: { type: string, enum: [invoice, request] }
        lineItems:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              description: { type: string }
              amount: { type: integer }
              quantity: { type: integer }
        totalAmount: { type: integer, description: "Minor units" }
        currency: { type: string }
        issuerDisplayName: { type: string }
        status: { type: string, enum: [issued, paid, settled_manual, void] }
    PaymentRequestInvite:
      type: object
      description: "Present on the create response only when the recipient was resolved via recipient_invite (#2210)."
      properties:
        id: { type: string }
        code: { type: string }
        url: { type: string }

  responses:
    SetupInvalid:
      description: "Invalid JSON or binding fields (setup_intent_invalid_request)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SetupIntentError" }
    SetupUnauthorized:
      description: "Missing, invalid or expired app-service token (SETUP_APP_UNAUTHORIZED); user sessions and shared keys are not accepted"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SetupIntentError" }
    SetupForbidden:
      description: "App inactive or operator scope revoked (SETUP_APP_UNAUTHORIZED), or app/buyer/seller/pledge/intent/customer/payment-method binding mismatch (setup_intent_binding_mismatch)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SetupIntentError" }
    SetupNotFound:
      description: "Resource not found on seller account (setup_intent_resource_not_found)"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SetupIntentError" }
    SetupUnavailable:
      description: "No active seller BYO connector (SELLER_NO_CARD_RAIL), registry/vault outage, rejected or insufficient seller key permissions, malformed Stripe response, timeout or upstream outage (SETUP_INTENT_UNAVAILABLE). No platform fallback."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SetupIntentError" }

paths:
  /api/transactions/lookup:
    post:
      tags: [pay]
      operationId: lookupAppOrderPayments
      summary: Read this registered app's order payment IDs by checkout session
      description: |
        Read-only batched lookup; no Stripe calls, settlement, writes or balance changes.
        Requires an app-service token with pay:read, approved per app through the
        operator-countersigned apps:service-scopes flow. This scope is NOT globally
        service-eligible. Active registration and approval are rechecked on every call.
        Cookies, delegated user tokens and shared service keys cannot authorize this read.

        Only rows whose first-class appDid equals the authenticated app, toDid equals
        sellerDid, externalRef is a requested checkout session and rail is stripe or
        stripe-byo are returned. Caller-supplied appDid or metadata cannot widen the query.
        Unknown, foreign-app, wrong-seller and legacy unbound sessions are all omitted;
        an empty match is 200 with transactions: []. Never infer ownership from metadata.
        Duplicate sessionIds are deduplicated. Results are ordered by transactionId;
        all matching rows are returned (session references are not globally unique).
        Consumers must group by seller and join by sessionId, not response position.

        metadata contains only string orderId, order_id, eventId, event_id, service
        and type values up to 200 characters. It is app-supplied descriptive data,
        never payment proof. All other keys, nested values, card/payment-method details,
        customer and PaymentIntent IDs, credentials and manifests are withheld.
        The only provider reference returned is the requested checkout sessionId.
        No status, amount or buyer identity is disclosed by this minimal read contract.
        Responses carry Cache-Control: no-store. Limited to 60 calls per minute per app.
        The app must authorize its organizer and derive sellerDid and sessionIds from
        its own stored orders before calling; app-service authority is not user authority.
        Checkouts must have been created using that app's own service token; no legacy
        backfill or cross-app access is provided.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sellerDid, sessionIds]
              properties:
                sellerDid:
                  type: string
                  maxLength: 500
                  pattern: "^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$"
                sessionIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string, maxLength: 255, pattern: "^cs_[A-Za-z0-9_]+$" }
      responses:
        "200":
          description: Matching app-and-seller-owned payments only
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [transactions]
                properties:
                  transactions:
                    type: array
                    items:
                      type: object
                      additionalProperties: false
                      required: [transactionId, sessionId, metadata]
                      properties:
                        transactionId: { type: string, description: "Kernel pay.transactions primary key, not a Stripe PaymentIntent ID" }
                        sessionId: { type: string, description: "The requested checkout session ID" }
                        metadata:
                          type: object
                          additionalProperties: false
                          properties:
                            orderId: { type: string, maxLength: 200 }
                            order_id: { type: string, maxLength: 200 }
                            eventId: { type: string, maxLength: 200 }
                            event_id: { type: string, maxLength: 200 }
                            service: { type: string, maxLength: 200 }
                            type: { type: string, maxLength: 200 }
        "400":
          description: Invalid JSON, missing sellerDid, or invalid/empty/oversized sessionIds
        "401":
          description: Missing, invalid or expired app-service bearer credential
        "403":
          description: Non-service token, missing pay:read scope, inactive app or revoked operator approval
        "429":
          description: Per-app request limit exceeded; Retry-After header specifies seconds
        "500":
          description: Registry or payment lookup unavailable; no internal details returned
  /api/setup-intent:
    post:
      tags: [pay]
      operationId: createAppSetupIntent
      summary: Save a pledge card on the seller's own Stripe account
      description: |
        Registered-app only. The app must request pay:setup-intent and an
        operator must approve it via the countersigned apps:service-scopes
        card before minting an app-service token. The scope is NOT globally
        service-eligible. Registry status and approval are rechecked every call.
        pay:settle, user sessions, delegated tokens and shared keys cannot
        create or verify intents.

        The seller must have an active sealed BYO restricted key and an active
        stripe:events grant. The restricted key needs Customers = Write,
        Setup Intents = Write (including read), and Payment Methods = Read,
        in addition to existing connector permissions. Stripe permission errors
        fail closed with 503. No platform credential is used or accepted.

        Creates a fresh customer with buyerDid/did metadata, then an off-session,
        card-only SetupIntent. Reserved intent metadata appDid, pledgeId, eventId,
        buyerDid, sellerDid, amount and currency comes only from the token and
        validated app body; caller metadata cannot overwrite it. These bindings
        must not be edited after creation.

        Legacy platform SetupIntents/customer IDs cannot be verified or migrated
        by this contract. Pending legacy pledges need a fresh setup on the seller
        account; do not confirm them by trusting client paymentMethodId.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetupIntentBinding" }
      responses:
        "200":
          description: Seller-account SetupIntent created; no charge made
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [clientSecret, setupIntentId, customerId]
                properties:
                  clientSecret: { type: string }
                  setupIntentId: { type: string }
                  customerId: { type: string }
        "400": { $ref: "#/components/responses/SetupInvalid" }
        "401": { $ref: "#/components/responses/SetupUnauthorized" }
        "403": { $ref: "#/components/responses/SetupForbidden" }
        "404": { $ref: "#/components/responses/SetupNotFound" }
        "503": { $ref: "#/components/responses/SetupUnavailable" }
  /api/setup-intent/verify:
    post:
      tags: [pay]
      operationId: verifyAppSetupIntent
      summary: Verify a succeeded pledge SetupIntent and its attached buyer card
      description: |
        Same registered-app, live operator approval and seller BYO prerequisites
        as creation. Retrieves the SetupIntent, customer and payment method using
        only the seller's sealed key. Every reserved metadata field must match
        the request and creating app DID. The intent must be succeeded and name
        the expected customer; the customer must not be deleted and buyerDid/did
        metadata must identify the buyer without conflicts. The payment method
        must belong to that customer. Another app cannot verify the intent even
        with its own operator-approved scope.

        The exact success response is the authoritative verified binding. Only
        its paymentMethodId may be stored by the app. Verification performs GETs
        only and is read-only/idempotent; it neither confirms a pledge nor moves
        money. Each Stripe request has a bounded 10-second timeout.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetupIntentVerifyRequest" }
      responses:
        "200":
          description: Verified binding; the payment method is ready for later off-session charging
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetupIntentVerified" }
        "400": { $ref: "#/components/responses/SetupInvalid" }
        "401": { $ref: "#/components/responses/SetupUnauthorized" }
        "403": { $ref: "#/components/responses/SetupForbidden" }
        "404": { $ref: "#/components/responses/SetupNotFound" }
        "409":
          description: "SetupIntent has not succeeded (setup_intent_not_succeeded)"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetupIntentError" }
        "503": { $ref: "#/components/responses/SetupUnavailable" }
  /api/health:
    get:
      tags: [pay]
      operationId: healthCheck
      security: []
      summary: Service health check — checks all configured payment providers
      responses:
        "200":
          description: Service healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [healthy, degraded, unhealthy] }
                  providers: { type: object }
                  timestamp: { type: string, format: date-time }
        "503":
          description: One or more providers unhealthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  providers: { type: object }
                  timestamp: { type: string, format: date-time }

  /api/balance/{did}:
    get:
      tags: [pay]
      operationId: getBalance
      summary: Get current balance for a DID
      description: |
        Returns the MJN (withdrawable) and MJNx (emitted, never withdrawable)
        balances as an explicit array (#2016). The authenticated identity
        must match the requested DID.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: did
          in: path
          required: true
          schema: { type: string }
          description: "URL-encoded DID"
      responses:
        "200":
          description: Balance retrieved
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden — can only access your own balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/balance/gift:
    post:
      tags: [pay]
      operationId: giftCredits
      summary: Bulk gift credits from a business DID to multiple recipients
      description: |
        A funded transfer, never a mint (#2018): debits `from_did`'s MJN (cash) balance for
        the total gifted `cash_amount` and its MJNx (credit) balance for the total gifted
        `credit_amount`, then atomically credits each recipient's cash and credit buckets.
        Insufficient balance in either unit returns 402 and writes nothing.
        The authenticated identity must match `from_did`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_did, recipients]
              properties:
                from_did: { type: string }
                recipients:
                  type: array
                  items:
                    type: object
                    required: [did, cash_amount, credit_amount]
                    properties:
                      did: { type: string }
                      cash_amount: { type: number }
                      credit_amount: { type: number }
                metadata: { type: object }
      responses:
        "200":
          description: Credits gifted
          content:
            application/json:
              schema:
                type: object
                properties:
                  batchId: { type: string }
                  count: { type: number }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: Insufficient MJN or MJNx balance — never a mint (#2018)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden — can only gift from your own DID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/balance/event-topup:
    post:
      tags: [pay]
      operationId: eventTopup
      summary: Multiplier-based credit gifting for event attendees
      description: |
        A funded transfer, never a mint (#2018): debits `from_did`'s MJN (cash) balance for
        `ticket_price × recipient count` and its MJNx (credit) balance for the total bonus
        credited. Credits each recipient with `ticket_price` cash (refund) and
        `ticket_price × (multiplier - 1)` credits (bonus).
        multiplier 1.0 = refund only; multiplier 10.0 = refund + 9× credits.
        Insufficient balance in either unit returns 402 and writes nothing.
        Authenticated via Bearer token; `from_did` must match session.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_did, event_id, multiplier, recipient_dids, metadata]
              properties:
                from_did: { type: string }
                event_id: { type: string }
                multiplier: { type: number, minimum: 1.0, description: "Credit multiplier (≥ 1.0)" }
                recipient_dids:
                  type: array
                  items: { type: string }
                  minItems: 1
                metadata:
                  type: object
                  required: [ticket_price]
                  properties:
                    ticket_price: { type: number, description: "Per-recipient ticket price (USD)" }
      responses:
        "200":
          description: Event credits applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  batchId: { type: string }
                  count: { type: number }
                  cashPerRecipient: { type: number }
                  creditPerRecipient: { type: number }
        "400":
          description: Invalid multiplier or missing ticket_price
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: Insufficient MJN or MJNx balance — never a mint (#2018)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden — can only top up from your own DID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/balance/transfer:
    post:
      tags: [pay]
      operationId: transferBalance
      summary: Transfer balance from one DID to another
      description: |
        Moves a single wallet unit (#2016) — `unit` defaults to `MJN` and must be
        `MJN` or `MJNx`; both legs of the transfer touch the SAME unit, so
        cross-unit movement (e.g. sending MJNx and having it arrive as MJN)
        is impossible by construction. An unknown unit is a 400, never a
        conversion. The sender's debit is a guarded conditional UPDATE (#2166)
        in the same transaction as the recipient credit — insufficient balance
        returns 402 and writes nothing. The authenticated identity must match
        `from_did`. Accepts either a session cookie or a Bearer token (app
        token or session JWT) — the route resolves the caller via
        `resolveEffectiveDid`, same as `GET /api/balance/{did}` (#2002).
      security:
        - cookieAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from_did, to_did, amount]
              properties:
                from_did: { type: string }
                to_did: { type: string }
                amount: { type: number, description: "Amount in the given unit" }
                unit: { type: string, enum: [MJN, MJNx], default: MJN }
                metadata: { type: object }
      responses:
        "200":
          description: Transfer complete
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactionId: { type: string }
                  fromDid: { type: string }
                  toDid: { type: string }
                  amount: { type: number }
                  unit: { type: string, enum: [MJN, MJNx] }
        "400":
          description: Invalid request — missing fields, non-positive amount, self-transfer, unknown unit, or currency mismatch
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: Insufficient balance in the requested unit (#2166) — never a partial write
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden — can only transfer from your own DID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/balance/topup:
    post:
      tags: [pay]
      operationId: topupBalance
      summary: Start a receipt-backed Stripe topup for the authenticated user
      description: |
        Legacy alias of `POST /api/topup/stripe`, not a direct balance-credit endpoint.
        Requires the user's session Cookie (session-only auth). Optional `did`, when present,
        must equal the session's effective DID. Returns a Stripe checkout URL/session id and
        records a pending transaction; only the confirmed receipt may credit MJN.
        Caller amounts never mint cash. An app-service token alone is not user authority.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                did: { type: string, description: "Optional; must equal the session's effective DID" }
                amount: { type: number, minimum: 20, description: "CAD dollars; minimum $20" }
                absorbFees: { type: boolean, description: "false grosses up the checkout so the user absorbs processor fees" }
      responses:
        "200":
          description: Stripe checkout created; balance is not yet credited
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  sessionId: { type: string }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized (missing user session)
        "403":
          description: Cannot top up another DID
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Too many requests

  /api/topup/stripe:
    post:
      tags: [pay]
      operationId: createStripeTopup
      summary: Start a Stripe checkout for the session user's MJN topup
      description: |
        Requires a user session Cookie, never app-service authority. Creates a pending transaction
        and returns a checkout URL; only the confirmed receipt may credit withdrawable MJN.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 20, description: "CAD dollars" }
                absorbFees: { type: boolean }
      responses:
        "200":
          description: Checkout created; no balance credited yet
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
                  sessionId: { type: string }
        "400":
          description: Invalid amount or JSON
        "401":
          description: Missing user session Cookie
        "429":
          description: Too many requests

  /api/topup/emt:
    post:
      tags: [pay]
      operationId: createEmtTopup
      summary: Record a pending EMT topup for the session user
      description: |
        Requires a user session Cookie. Returns payment instructions and a pending transaction;
        no balance is credited until the incoming payment is matched by an administrator.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount: { type: number, minimum: 20, description: "CAD dollars" }
      responses:
        "200":
          description: Pending topup recorded; no balance credited yet
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  transactionId: { type: string }
                  instructions:
                    type: object
                    properties:
                      email: { type: string }
                      amount: { type: number }
                      memo: { type: string }
        "400":
          description: Invalid amount or JSON
        "401":
          description: Missing user session Cookie
        "429":
          description: Too many requests

  /api/charge:
    post:
      tags: [pay]
      operationId: createCharge
      summary: Create a direct payment charge
      description: |
        For the `stripe` provider: creates a PaymentIntent (requires frontend to confirm with `clientSecret`).
        For Solana: returns an unsigned transaction (requires wallet signature).
        The recipient names a rail customer with the rail-generic `to.customerId` (+ optional
        `to.provider`); the original `to.stripeCustomerId` keeps working as an alias (#2177).
        Auth is optional — if provided, the charged DID is linked to the transaction.
        Card charges require `to.did` (or `sellerDid` for a customer-only recipient).
        Credentials come only from that seller's sealed Stripe connector; missing secret
        or publishable key returns `SELLER_NO_CARD_RAIL`, never a node account fallback.
        Rate-limited to 10 requests/minute per IP before any writes, including anonymous calls.
        A pending kernel row is inserted before creating the PaymentIntent; its protected
        payTransactionId metadata lets the verified seller webhook complete it. A supplied
        idempotencyKey permanently claims one seller/payer/app-scoped attempt; repeats return
        409 without creating another PaymentIntent, including beyond Stripe retention.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency, to]
              properties:
                amount: { type: integer, description: "Cents for fiat; lamports for SOL; base units for tokens" }
                currency:
                  type: string
                  enum: [USD, CAD, SOL, USDC, MJN]
                to:
                  type: object
                  description: "Recipient — at least one field required"
                  properties:
                    customerId: { type: string, description: "Rail customer id; the rail is `provider` (default `stripe`)" }
                    provider: { type: string, enum: [stripe], description: "Rail `customerId` belongs to — only `stripe` today" }
                    stripeCustomerId: { type: string, description: "Legacy alias of `customerId` (provider `stripe`); wins if both are sent" }
                    solanaAddress: { type: string }
                    did: { type: string }
                description: { type: string }
                metadata:
                  type: object
                  additionalProperties: { type: string }
                idempotencyKey: { type: string }
                sellerDid: { type: string, description: "Seller DID for card charges when to.did is absent" }
      responses:
        "200":
          description: Charge created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  provider: { $ref: "#/components/schemas/PaymentProvider" }
                  status: { type: string, enum: [pending, requires_action, succeeded, failed] }
                  amount: { type: number }
                  currency: { type: string }
                  clientSecret: { type: string, description: "Rail client confirmation secret for the frontend (Stripe: PaymentIntent client secret)" }
                  publishableKey: { type: string, description: "Client key from the same seller connector as the PaymentIntent" }
                  signature: { type: string, description: "Solana — if pre-signed" }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: SELLER_CHARGE_ALREADY_ATTEMPTED — reconcile the existing attempt, never retry with a new key
        "429":
          description: Too many requests from this IP

  /api/checkout:
    post:
      tags: [pay]
      operationId: createCheckout
      summary: Create a hosted Stripe Checkout session
      description: |
        Returns a URL to redirect the customer to Stripe Checkout. Supports `.fair` manifests for
        multi-party revenue splits via `fairManifest`. Rate-limited to 10 req/min per IP.

        A seller checkout requires a registered app's app-service token (#2866): the checkout
        row is bound to the verified app's DID and records the declared `payeeManifest` (falling
        back to `fairManifest`). The returned `transactionId` identifies the pending payment
        completed by the seller webhook. Requires the operator-approved `pay:settle` scope (403 otherwise).
        Checkout requires a seller DID (also accepted as `metadata.sellerDid`).
        Seller-owned charges settle through the seller's webhook, not `/api/settle`;
        no platform balance moves. Anonymous, user-session and delegated-attestation callers
        cannot open seller checkouts (401). Paid notifications go only to the recorded app.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items, currency, successUrl, cancelUrl]
              properties:
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [name, amount, quantity]
                    properties:
                      name: { type: string }
                      description: { type: string }
                      amount: { type: integer, description: "Price in cents" }
                      quantity: { type: integer }
                      image: { type: string }
                currency:
                  type: string
                  enum: [USD, CAD, EUR, GBP]
                mode: { type: string, enum: [payment], default: payment }
                customerEmail: { type: string, format: email }
                successUrl: { type: string, format: uri }
                cancelUrl: { type: string, format: uri }
                metadata:
                  type: object
                  additionalProperties: { type: string }
                fairManifest: { type: object, description: ".fair multi-party split manifest" }
                payeeManifest: { type: object, description: "App-authenticated checkout only (#2642): the payee manifest recorded on the payment and verified at settle time. Defaults to fairManifest." }
                sellerDid:
                  type: string
                  description: |
                    #2757: the seller whose OWN Stripe account (the BYO connector, #1785) is charged.
                    A seller with no working card rail is a 400 `SELLER_NO_CARD_RAIL`.
                    Absent (including metadata.sellerDid) is rejected; no platform-own fallback.
      responses:
        "200":
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  url: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }
                  transactionId: { type: string }
        "400":
          description: >-
            Invalid request, or (#2757) the seller has no card rail — `code` `SELLER_NO_CARD_RAIL` — or
            `mode: subscription` with a seller (`code` `SUBSCRIPTION_NOT_SUPPORTED`)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      code: { type: string, description: "Stable machine-readable reason, e.g. SELLER_NO_CARD_RAIL" }
        "401":
          description: Seller checkout requires a valid app service token, not a user session or delegated attestation
        "403":
          description: App is revoked or lacks the operator-approved pay:settle scope
        "429":
          description: Too many requests
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: >-
            Stripe refused or could not be reached on the seller's own account — `code` is one of
            `CARD_RAIL_KEY_REJECTED`, `CARD_RAIL_UNAVAILABLE`, `CARD_RAIL_REQUEST_REJECTED`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/settle:
    post:
      tags: [pay]
      operationId: fairSettle
      summary: Settle a payment a registered app created at checkout (#2642)
      description: |
        The registered-app settlement contract. Authenticated with the calling
        app's OWN app-service token (`typ: app-service+jwt`, minted by
        `POST /auth/api/apps/token/service`) carrying the operator-approved
        `pay:settle` scope (granted per app through the operator-countersigned
        `apps:service-scopes` card, #2711). The kernel re-checks the approval
        against `registry.apps` on every call, so a revoked scope or app stops
        settling immediately.

        There is no shared pay key; legacy bearer credentials are refused with 401.
        Internal settlers (the bus `settle` reactor, query-cost
        settlement) call `settlePayment()` in-process instead.

        The payment is the `transactionId` returned by an app-authenticated
        `POST /api/checkout` (an app-service token on that request records the
        app DID and the declared payee manifest on `pay.transactions`). The kernel
        settles it only when:
        - the payment is bound to the calling app's DID (another app's payment,
          or a payment with no app binding, is a 403);
        - the rail has confirmed it (`status = completed`; otherwise 409);
        - the posted `fair_manifest.chain` (and `taxCredits`) equals the payee
          manifest recorded at checkout — same DIDs, roles and amounts (otherwise 403).

        Payer, amount, currency, service, type and rail come from the kernel's
        own payment record, never from the caller; settlement is always
        externally funded (no app token can debit a user balance).

        A second settle of an already-settled payment is idempotent: it returns
        the prior result with `alreadySettled: true` and pays nothing twice.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transaction_id, fair_manifest]
              properties:
                transaction_id: { type: string, description: "The transactionId returned by the app-authenticated checkout" }
                total_amount: { type: number, description: "Optional; must equal the recorded payment amount (dollars) when present" }
                from_did: { type: string, description: "Optional; must equal the recorded payer when present" }
                fair_manifest:
                  type: object
                  required: [chain]
                  properties:
                    chain:
                      type: array
                      items:
                        type: object
                        required: [did, amount, role]
                        properties:
                          did: { type: string }
                          amount: { type: number }
                          role: { type: string }
                    taxCredits:
                      type: array
                      items:
                        type: object
                        required: [did, amount, jurisdiction, kind, remitTo, registrationNumber]
                        properties:
                          did: { type: string }
                          amount: { type: number }
                          jurisdiction: { type: string }
                          kind: { type: string }
                          rateBps: { type: number }
                          remitTo: { type: string }
                          registrationNumber: { type: string }
                metadata: { type: object }
      responses:
        "200":
          description: Settlement complete (or the prior result of an already-settled payment)
          content:
            application/json:
              schema:
                type: object
                properties:
                  settled: { type: boolean }
                  batchId: { type: string }
                  transactions: { type: array, items: { type: string } }
                  total_amount: { type: number }
                  recipients: { type: integer }
                  source: { type: string }
                  alreadySettled: { type: boolean, description: "true when this call replayed an earlier settlement" }
        "400":
          description: Invalid request or invalid manifest (chain does not sum to the payment total)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized (missing/invalid/expired app-service token — including the retired shared API key)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden (no operator-approved pay:settle, payment not created by this app / no app binding, or manifest does not match the recorded payee manifest)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Unknown payment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Payment not yet confirmed by the rail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/escrow:
    post:
      tags: [pay]
      operationId: createEscrow
      summary: Create an escrow — hold funds until released
      description: |
        Holds funds from `from` until conditions are met (time-based auto-release or
        multi-signature release). Requires Bearer auth.
        Card holds use the recipient `to` DID's sealed Stripe connector. The returned
        clientSecret and publishableKey belong to that account. No node credential fallback.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency, from, to]
              properties:
                amount: { type: number }
                currency: { type: string }
                from: { type: string, description: "DID of depositor" }
                to: { type: string, description: "DID of recipient" }
                arbiter: { type: string, description: "DID of dispute resolver" }
                conditions:
                  type: object
                  properties:
                    releaseAfter: { type: string, format: date-time, description: "ISO date for auto-release" }
                    requireSignatures:
                      type: array
                      items: { type: string }
                      description: "DIDs that must sign to release"
                metadata:
                  type: object
                  additionalProperties: { type: string }
      responses:
        "201":
          description: Escrow created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  provider: { $ref: "#/components/schemas/PaymentProvider" }
                  status: { type: string, enum: [held] }
                  amount: { type: number }
                  currency: { type: string }
                  from: { type: string }
                  to: { type: string }
                  clientSecret: { type: string }
                  publishableKey: { type: string }
                  createdAt: { type: string, format: date-time }
        "400":
          description: Invalid request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Authentication required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    put:
      tags: [pay]
      operationId: releaseEscrow
      summary: Release or refund an escrow
      description: |
        Card updates use the persisted escrow recipient's sealed connector, not caller metadata.
        Release/capture is allowed only for the recorded depositor (`fromDid`) or arbiter.
        Refund/cancel is allowed only for the recorded recipient (`toDid`) or arbiter.
        Unsupported actions are rejected rather than defaulting to capture. Legacy platform
        escrows fail clearly. Capture/cancel updates the escrow row synchronously to
        released/refunded; it is not a pending hosted checkout awaiting a completion webhook.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, escrowId, provider]
              properties:
                action: { type: string, enum: [release, refund] }
                escrowId: { type: string }
                provider: { type: string, enum: [stripe] }
      responses:
        "200":
          description: Escrow settled
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [released, refunded] }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/card-rail/check:
    get:
      tags: [pay]
      operationId: cardRailCheck
      summary: Can this seller take a card payment right now? (#2757)
      description: |
        Public, rate-limited. Tells buyer-facing pages (an event's ticket panel)
        whether the seller at `did` has a working card rail — their own Stripe
        connector (#1785): a sealed restricted key AND the `stripe:events` grant —
        so they show a card button only when it would work. Replaces the removed
        `/api/connect/check`. One boolean; never the key, the grant or the account.
      parameters:
        - name: did
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Card availability
          content:
            application/json:
              schema:
                type: object
                properties:
                  cardEnabled: { type: boolean }
        "400":
          description: did query parameter is required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Too many requests
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/transactions/{did}:
    get:
      tags: [pay]
      operationId: listTransactions
      summary: List transactions for a DID
      description: Returns paginated transactions. The authenticated identity must match the requested DID.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: did
          in: path
          required: true
          schema: { type: string }
        - name: service
          in: query
          schema: { type: string, description: "Filter by originating service (e.g. coffee)" }
        - name: type
          in: query
          schema: { type: string, description: "Filter by transaction type" }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Transaction list
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items: { $ref: "#/components/schemas/Transaction" }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Forbidden — can only access your own transactions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/transactions/{did}/summary:
    get:
      tags: [pay]
      operationId: transactionSummary
      summary: Aggregated transaction summary grouped by service
      description: Returns earned/spent breakdown by service. The authenticated identity must match the requested DID.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: did
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  services:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        earned: { type: number }
                        spent: { type: number }
                        net: { type: number }
                  total:
                    type: object
                    properties:
                      earned: { type: number }
                      spent: { type: number }
                      net: { type: number }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/webhook:
    post:
      tags: [pay]
      operationId: stripeWebhook
      x-rail-generic-alias: railWebhook
      deprecated: true
      summary: Retired unbound Stripe webhook
      description: |
        Always rejects: no connector is bound to this endpoint. Configure the
        owner's `/api/webhook/did/{routingId}` URL from the Stripe connector card.
      security:
        - stripeWebhook: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "400":
          description: A connector-bound webhook endpoint is required

  /api/webhook/{provider}:
    post:
      tags: [pay]
      operationId: railWebhook
      deprecated: true
      summary: Retired unbound rail webhook alias
      description: |
        The stripe alias rejects unbound deliveries, exactly like `/api/webhook`.
        An unknown provider is 404. Use `/api/webhook/did/{routingId}` instead.
      security:
        - railWebhookSignature: []
      parameters:
        - $ref: "#/components/parameters/provider"
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: Event received
        "404":
          description: Unknown provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/webhook/did/{routingId}:
    post:
      tags: [pay]
      operationId: didStripeWebhook
      summary: Verify and route an owner's Stripe connector event
      description: |
        The opaque routing ID selects a stored connector owner before any payload
        is parsed. Verify the raw body with that owner's vault-sealed signing
        secret, never with an account or identity selected by event metadata.
        Requires sealed credentials AND the owner's signed stripe:events grant;
        sealing credentials never grants consent. Forward payment_intent.succeeded,
        payment_intent.payment_failed, checkout.session.completed,
        customer.subscription.created, customer.subscription.updated,
        customer.subscription.deleted, invoice.paid and payout.paid onto the owner's
        bus. The source of truth is src/lib/stripe/webhook-events.ts. Subscriptions
        have no event-level amount. Raw Stripe objects remain in a private in-process
        relay; bus payloads contain only safe references and monetary facts.
        Only payment_intent.succeeded invokes existing owner-scoped transaction/invoice
        settlement; the other mapped types are bus delivery, not new business handlers.
        Owner events never dispatch to retired unscoped platform settlement handlers.
        transfer.created remains a legacy-only withdrawal path, not a connector event.
        The node is a DID using the same connector flow for topups.
        Successful event IDs are deduplicated per owner; stale signatures are
        rejected. Failed processing is not marked complete so Stripe may retry.
      security:
        - stripeWebhook: []
      parameters:
        - name: routingId
          in: path
          required: true
          description: Opaque routing ID returned by the authenticated connector setup card
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: Verified event handled, ignored or already processed
        "400":
          description: Missing or invalid signature, stale timestamp, malformed payload or missing signing secret
        "404":
          description: Unknown connector routing ID
        "500":
          description: Processing failed; retry the delivery
        "503":
          description: Connector key or events grant unavailable

  /api/refund:
    post:
      tags: [pay]
      operationId: refund
      summary: Issue a refund for a transaction
      description: |
        Requires the app's own service token with operator-approved `pay:refund`.
        The kernel resolves the original transaction and refuses another app's payment
        or an unbound payment before refund or ledger side effects. Scope approval is
        checked live. No shared service key or user session substitutes for the app.
        Only `stripe-byo` payments are supported: the persisted `toDid` selects the
        account that took the charge. Legacy platform refunds fail with
        `LEGACY_PLATFORM_PAYMENT` and a log line. BYO refunds do not move node balances.
        Caller-controlled PaymentIntent metadata is never authority.
        Verified BYO settlement records settledPaymentIntentId with first-class settledAt;
        PI-only refunds match that provenance in SQL without Stripe session lookups.
        Older seller sessions use a filtered, ordered fallback capped at 25 candidates.
        A disconnected seller is skipped and cannot abort another seller's refund.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [paymentId]
              properties:
                paymentId: { type: string, description: "Stripe payment/session id resolved to the original kernel transaction" }
                amount: { type: integer, minimum: 1, description: "Partial refund amount in cents (omit to refund the remaining amount)" }
                reason: { type: string }
      responses:
        "200":
          description: Refund issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  transactionId: { type: string }
                  status: { type: string }
        "400":
          description: Invalid refund request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized
        "403":
          description: Missing approved pay:refund scope, another app's payment or no app binding
        "404":
          description: Unknown payment


  /api/charge-pledges:
    post:
      tags: [pay]
      operationId: chargePledges
      summary: Charge confirmed pledge setups belonging to the calling app
      description: |
        Requires the app's own service bearer with operator-approved `pay:charge-pledges`.
        Every pledge must name `sellerDid`, `pledgeId` and `setupIntentId`. The seller DID
        selects the sealed BYO connector through the one shared DID resolver. The kernel
        verifies #2788's SetupIntent binding against the authenticated app and requested
        seller, pledge and event, then checks the customer buyer and saved card attachment
        on that same account. Amount, currency, customer and card come only from the verified
        Stripe setup; caller-supplied payment details are ignored.
        All references are structurally validated before preflight or charging; malformed
        or duplicate references return HTTP 400. The entire batch is preflighted before
        any charge. Binding, seller rail, setup and charge failures are safe per-pledge
        outcomes: a valid batch always returns HTTP 200 with every result in request order,
        continuing after individual failures. No kernel pledge row, events API lookup or platform key is used.
        A permanent Stripe SetupIntent attempt marker is written before charging.
        Concurrent attempts use identical Stripe idempotency keys and parameters; later
        replays report PLEDGE_CHARGE_ALREADY_ATTEMPTED for each marked pledge without
        another charge, including beyond Stripe's retention. A marker is not proof of
        successful payment: replayed attempts are failed/reconciliation outcomes, never
        assumed charged. Initially invalid bindings are also included in replay results.
        Ambiguous outcomes require reconciliation; this flow never clears the marker.
      security:
        - appServiceToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eventId, pledges]
              properties:
                eventId: { type: string }
                pledges:
                  type: array
                  items:
                    type: object
                    required: [pledgeId, setupIntentId, sellerDid]
                    properties:
                      pledgeId: { type: string }
                      setupIntentId: { type: string }
                      sellerDid: { type: string, description: "Transacting seller DID, verified against the seller-account setup binding" }
      responses:
        "200":
          description: Full ordered batch outcomes, including preflight, charge and replay failures
          content:
            application/json:
              schema:
                type: object
                required: [charged, failed, total, results]
                properties:
                  charged: { type: integer, minimum: 0 }
                  failed: { type: integer, minimum: 0 }
                  total: { type: integer, minimum: 0 }
                  results:
                    type: array
                    items:
                      type: object
                      required: [pledgeId, status]
                      properties:
                        pledgeId: { type: string }
                        status: { type: string, enum: [charged, failed] }
                        error: { type: string, description: "Safe failure text; never upstream error details" }
                        code:
                          type: string
                          description: "Safe failure code, including SELLER_NO_CARD_RAIL, PLEDGE_BINDING_MISMATCH, PLEDGE_SETUP_NOT_FOUND, PLEDGE_CHARGE_ALREADY_ATTEMPTED, PLEDGE_CHARGE_RECONCILIATION_REQUIRED, PLEDGE_CHARGE_REQUIRES_ACTION or PLEDGE_CHARGE_UNAVAILABLE"
        "401":
          description: Missing or invalid app-service token
        "403":
          description: Missing approved scope
        "400":
          description: Invalid request shape, eventId or pledge references
        "429":
          description: Rate limit exceeded

  /api/emission:
    post:
      tags: [pay]
      operationId: closedEmissionIngress
      summary: Closed HTTP ingress — emission is kernel-internal
      description: "Emission runs through the kernel's in-process executor; no HTTP bearer credential grants mint authority."
      security: []
      responses:
        "403":
          description: HTTP emission ingress is closed

  /api/admin/reconciliation:
    get:
      tags: [pay]
      operationId: payReconciliation
      summary: MJN/MJNx reconciliation report (#2016 decision 4) + withdrawal discrepancies (#2172)
      description: |
        Circulating MJNx (emitted, in-platform unit), backed MJN (withdrawable
        liability), and per-DID lifetime emitted vs receipted totals. Adapted
        from the #2012 audit's §6 SQL sketch to the row-per-(did, unit)
        `pay.balances` shape. Single-node only — no cross-node breakdown
        (#738 Decision 2). Also surfaces withdrawal-reconciliation discrepancies
        (#2172), grouped by rail — signed `pay.reconciliation.discrepancy`
        attestations the reconciliation sweep emitted, proposing but never
        applying a resolution.
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Reconciliation totals
          content:
            application/json:
              schema:
                type: object
                properties:
                  circulatingMjnx: { type: number }
                  backedMjn: { type: number }
                  perDid:
                    type: array
                    items:
                      type: object
                      properties:
                        did: { type: string }
                        currency: { type: string }
                        mjn_amount: { type: number }
                        mjnx_amount: { type: number }
                        lifetime_emitted: { type: number }
                        lifetime_receipted: { type: number }
                  discrepancies:
                    type: array
                    description: Withdrawal reconciliation discrepancies (#2172), grouped by rail.
                    items:
                      type: object
                      properties:
                        rail: { type: string }
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              bucket: { type: string, enum: [external_without_ledger, pending_timeout] }
                              externalRef: { type: [string, "null"] }
                              intentId: { type: [string, "null"] }
                              amount: { type: number }
                              unit: { type: string }
                              issuedAt: { type: string, format: date-time }
        "401":
          description: Unauthorized (not an admin)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/admin/payment-requests/{id}/retry-settlement:
    post:
      tags: [pay]
      operationId: retryPaymentRequestSettlement
      summary: Re-run the on-platform settlement for a Stripe-paid payment_request — operator-only (#2439)
      description: |
        Retry path for `payment_request.settlement_failed`: the buyer paid
        through Stripe but the `.fair` ledger settlement failed or was
        skipped (`reason`: `empty_chain`, `basis_mismatch`,
        `settle_rejected`, `settle_error`). Fix the cause, then call this.
        Refuses (409) unless the request is `paid` via Stripe with no
        settlement ledger rows yet, so it can never settle twice. A retry
        that still fails re-publishes `payment_request.settlement_failed`
        and returns 422.
      security:
        - cookieAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Settled
          content:
            application/json:
              schema:
                type: object
                properties:
                  paymentRequestId: { type: string }
                  settled: { type: boolean }
        "401":
          description: Unauthorized (not an admin)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Not paid via Stripe, or settlement ledger rows already exist
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: The settlement still cannot complete (reason in `error`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests:
    post:
      tags: [pay]
      operationId: createPaymentRequest
      summary: Create a payment_request (invoice / money request) — issuer-only
      description: |
        #2206/#2207/#2208/#2210. `issuer_did` must resolve to the
        authenticated principal (`requireAuth`/`actingFor`). Exactly one
        of `recipient_did` / `recipient_stub_id` / `recipient_invite` is
        required:
          - `recipient_did` must be a DID the issuer has an existing
            connection with (403 otherwise).
          - `recipient_stub_id` passes a pre-resolved claimable-stub DID
            through directly.
          - `recipient_invite` (`{ email, delivery?: 'link'|'email',
            note? }`) creates-or-reuses a claimable stub (#1834) for that
            email and a connections invite carrying this request as its
            opaque reason (#1839 no-PII-pre-claim posture); the response's
            `invite` field carries the resulting invite id/code/url.
        `total` is computed from `line_items`. When `fair_manifest` is
        omitted, a default single-payee manifest is built via
        `buildFairManifest`; a caller-supplied manifest is rejected (400)
        when its `total` does not match the computed request total. Mints
        exactly ONE `payment_request.issued` attestation (signed by the
        issuer, binding `content_hash`) and publishes
        `payment_request.issued`. Every request also gets an opaque
        `payHandle` for the unauthenticated by-handle read.
      security:
        - cookieAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issuer_did, line_items]
              properties:
                issuer_did: { type: string }
                payee_account: { type: string, description: "Defaults to issuer_did" }
                kind: { type: string, enum: [invoice, request], default: invoice }
                recipient_did: { type: string }
                recipient_stub_id: { type: string }
                recipient_invite:
                  type: object
                  description: "New-counterparty path (#2210) — exactly one of recipient_did/recipient_stub_id/recipient_invite is required"
                  required: [email]
                  properties:
                    email: { type: string, format: email }
                    delivery: { type: string, enum: [link, email], default: email }
                    note: { type: string }
                line_items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [name, amount, quantity]
                    properties:
                      name: { type: string }
                      description: { type: string }
                      amount: { type: integer, description: "Unit price in minor units" }
                      quantity: { type: integer }
                currency: { type: string, default: CAD }
                due_at:
                  description: >-
                    Due date, a calendar date (#2651). Send `YYYY-MM-DD`; it is stored as UTC midnight of that
                    day. A full ISO date-time is also accepted, and its UTC date is the due date.
                  anyOf:
                    - { type: string, format: date }
                    - { type: string, format: date-time }
                  examples: ["2026-10-06"]
                allow_on_platform: { type: boolean, default: true }
                fair_manifest: { type: object, description: "Optional — defaulted via buildFairManifest when omitted" }
      responses:
        "201":
          description: payment_request created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaymentRequest"
                  - type: object
                    properties:
                      invite: { $ref: "#/components/schemas/PaymentRequestInvite" }
        "400":
          description: Invalid request (bad recipient/line_items, or fair_manifest.total mismatch)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: issuer_did does not match the authenticated principal, or recipient_did is not an existing connection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [pay]
      operationId: listPaymentRequests
      summary: List payment_requests by issuer and/or recipient
      description: |
        At least one of `issuer_did` / `recipient_did` is required and must
        match the authenticated principal — a caller may only list their
        own issued or received payment_requests.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: issuer_did
          in: query
          schema: { type: string }
        - name: recipient_did
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [issued, paid, settled_manual, void] }
        - name: kind
          in: query
          schema: { type: string, enum: [invoice, request] }
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        "200":
          description: payment_requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  paymentRequests:
                    type: array
                    items: { $ref: "#/components/schemas/PaymentRequest" }
        "400":
          description: Neither issuer_did nor recipient_did was supplied
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: issuer_did/recipient_did does not match the authenticated principal
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/{id}:
    get:
      tags: [pay]
      operationId: getPaymentRequest
      summary: Get a payment_request — issuer or recipient only
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: payment_request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentRequest" }
        "403":
          description: Not the issuer or recipient
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/by-handle/{handle}:
    get:
      tags: [pay]
      operationId: getPaymentRequestByHandle
      security: []
      summary: Unauthenticated minimum-necessary read for the opaque pay-link handle (#2210)
      description: |
        Used by both recipient orderings: pay-first (before any account/
        connection exists) and claim-first (after claiming). No
        recipient PII, no issuer DID. Hidden (404) once `void`, same as an
        unknown handle.
      parameters:
        - name: handle
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Minimum-necessary payment_request view
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentRequestPublicView" }
        "404":
          description: Not found, or void
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/{id}/void:
    post:
      tags: [pay]
      operationId: voidPaymentRequest
      summary: Void a payment_request — issuer-only, only valid from `issued`
      description: Publishes `payment_request.voided`.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: payment_request voided
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentRequest" }
        "403":
          description: Not the issuer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Not in `issued` status (already void/settled_manual/paid), or a concurrent status change
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/{id}/checkout:
    post:
      tags: [pay]
      operationId: createPaymentRequestCheckout
      summary: Create (or reuse) a Stripe Checkout session for a payment_request — issuer or recipient (#2209)
      description: |
        Composes the existing `POST /api/checkout` code path with the
        payment_request's own `line_items`/`currency`/`fair_manifest`
        (always present — #2207 NOT NULL)/`issuer_did`, and
        `metadata.payment_request_id`. Refuses when the request isn't
        `issued` or doesn't `allow_on_platform`. Idempotent: reuses an
        already-open Stripe session for the same request instead of
        creating a duplicate. Auth required — an anonymous pay-link
        checkout (no recipient DID) is deferred to #2210.

        On `checkout.session.completed`, the Stripe webhook
        (`POST /api/webhook`) reads `metadata.payment_request_id`: status
        → `paid`, `settlement_ref = { method: 'stripe', checkout_session_id,
        payment_intent_id }`, publishes `payment_request.paid`, ALWAYS
        settles via the canonical `settlePayment()` primitive (the manifest
        is guaranteed present), mints exactly ONE kernel-signed
        `payment_request.settled` attestation, and publishes
        `payment_request.settled`. Idempotent on webhook replay.

        #2754 — which account is charged: when the issuer has an active Stripe
        connector (#1785, a sealed restricted key AND the `stripe:events` grant)
        the session is created ON THE ISSUER'S OWN STRIPE ACCOUNT with that key
        (needs **Checkout Sessions = Write**, checked when the key is connected);
        without a connector the call is a 400 `SELLER_NO_CARD_RAIL` (#2757 —
        Stripe Connect is removed, there is no platform-account fallback). There
        is no platform fee on connector charges for now. Such a payment settles from
        the issuer's own `payment_intent.succeeded` (the PaymentIntent carries
        `metadata.payment_request_id`), not the platform webhook: `paid`, a
        `stripe-byo` transaction row, no platform balance movement.

        Error bodies are `{ error, code? }`; `code` is stable and drives the pay
        page's message: `SELLER_NO_CARD_RAIL` (400), and for a failure creating
        the session on the issuer's account (502) `CARD_RAIL_KEY_MISSING`,
        `CARD_RAIL_KEY_REJECTED`, `CARD_RAIL_UNAVAILABLE`, `CARD_RAIL_REQUEST_REJECTED`.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_email: { type: string, format: email }
                paidByDid:
                  type: string
                  description: |
                    #2656 — which DID pays: the caller's own DID, or an org/business
                    DID where they hold owner/admin in `identity_members` (the list
                    `GET /api/payment-requests/{id}/payer-dids` returns). Validated
                    server-side — a DID the caller can't act for is a 403 and is
                    never stored. Omitted = the request settles as its recipient.
      responses:
        "200":
          description: Checkout session created (or reused)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  url: { type: string, format: uri }
                  expiresAt: { type: string, format: date-time }
                  reused: { type: boolean, description: "true when an existing open Stripe session was reused" }
        "400":
          description: "Invalid request, allow_on_platform is false, or the issuer has no card rail (`code` SELLER_NO_CARD_RAIL, #2754)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, description: "Stable machine-readable reason, e.g. SELLER_NO_CARD_RAIL" }
        "403":
          description: Not the issuer or recipient, or `paidByDid` is a DID the caller can't act for (#2656)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Not in `issued` status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: The Checkout Session could not be created on the issuer's own Stripe account (#2754)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code:
                    type: string
                    enum: [CARD_RAIL_KEY_MISSING, CARD_RAIL_KEY_REJECTED, CARD_RAIL_UNAVAILABLE, CARD_RAIL_REQUEST_REJECTED]

  /api/payment-requests/{id}/payer-dids:
    get:
      tags: [pay]
      operationId: listPaymentRequestPayerDids
      summary: The DIDs the caller may pay a payment_request as (#2656)
      description: |
        Feeds the "Pay as" picker on `/pay/r/{handle}`: the caller's own DID
        plus every org/business DID where they hold `owner` or `admin` in
        `identity_members` (plain members are never offered one). The path
        segment is the opaque pay-link handle, as the pay page already passes
        it to `/checkout`. `POST /api/payment-requests/{id}/checkout` and
        `POST /api/payment-requests/by-handle/{handle}/emt` re-check the choice
        server-side, so this list is a convenience, not the enforcement.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The payment_request's pay-link handle
          schema: { type: string }
      responses:
        "200":
          description: The caller's payable identities
          content:
            application/json:
              schema:
                type: object
                properties:
                  dids:
                    type: array
                    items:
                      type: object
                      properties:
                        did: { type: string }
                        kind: { type: string, enum: [personal, organization] }
                        displayName: { type: string }
                  defaultDid: { type: string, description: "Preselected: the recipient when the caller controls it, else the DID they are acting as, else themselves" }
        "401":
          description: Not authenticated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Unknown or void handle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/rails:
    get:
      tags: [pay]
      operationId: getPaymentRequestRails
      summary: Which ways the issuer can be paid right now — drives the issue-time warning (#2754)
      description: |
        `{ card, emt }` for the authenticated issuer: `card` = a working card rail
        (their own Stripe connector), `emt` = an
        e-Transfer receiving email is set. Issuer-only — `issuer_did` must be the
        authenticated principal. Two booleans only; never the key, the account or
        the email.
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: issuer_did
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The issuer's rails
          content:
            application/json:
              schema:
                type: object
                properties:
                  card: { type: boolean }
                  emt: { type: boolean }
        "400":
          description: Missing issuer_did
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Not authenticated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: issuer_did is not the authenticated principal
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/by-handle/{handle}/emt:
    delete:
      tags: [pay]
      operationId: leavePaymentRequestEmt
      summary: '"Pay another way" — withdraw the e-Transfer choice, `emt_pending` -> `issued` (#2758)'
      description: |
        The payer chose e-Transfer (`POST` on this path, which moves the request
        `issued -> emt_pending`) and changed their mind. Guarded compare-and-swap:
        allowed only while no deposit has been confirmed — once the issuer has
        marked it paid this is a 409 and the request stays paid. Idempotent: a
        request already back at `issued` is a 200 with `reverted: false`. No auth
        (the opaque pay-link handle is the capability); shares the choice's per-IP
        rate limit. Choosing e-Transfer again returns the same memo. The issuer's
        confirm path is unchanged (an issuer can still settle a reverted request
        manually if the transfer arrives anyway).
      parameters:
        - name: handle
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The request is `issued` again (or already was)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  reverted: { type: boolean }
                  status: { type: string }
        "404":
          description: Unknown or void handle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: A payment has already been confirmed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: Rate limited
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/payment-requests/{id}/settle:
    post:
      tags: [pay]
      operationId: settlePaymentRequestManual
      summary: Record an off-platform manual settlement — issuer-only, only valid from `issued`
      description: |
        Sets `status` to `settled_manual` and `settlement_ref = { method:
        'manual', note, asserted_by, settled_at }`. Mints exactly ONE
        `payment_request.settled` attestation (`method: 'manual'`, signed
        by the issuer) and publishes `payment_request.settled`. On-platform
        settlement (`stripe`) transitions to `paid` (not `settled_manual`)
        instead — see `POST /api/payment-requests/{id}/checkout` (#2209).
      security:
        - cookieAuth: []
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              properties:
                method: { type: string, enum: [manual] }
                note: { type: string }
      responses:
        "200":
          description: payment_request settled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentRequest" }
        "400":
          description: Invalid method/note
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Not the issuer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Not in `issued` status, or a concurrent status change
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/spec:
    get:
      tags: [pay]
      operationId: getSpec
      security: []
      summary: Serve the OpenAPI spec YAML
      responses:
        "200":
          description: OpenAPI YAML
          content:
            text/yaml:
              schema: { type: string }
