openapi: 3.1.0
info:
  title: Outpost MoR — Stripe Billing Integration API
  version: 0.1.0
  summary: |
    The Outpost API consumed by merchants running Stripe Billing on their own
    Stripe account while delegating payment collection and money movement to
    Outpost as Merchant of Record.
  description: |
    This spec covers the merchant ⇄ Outpost contract for the
    **Stripe Billing + PaymentRecords** architecture.

    There are two directions:

    * **Outbound (merchant → Outpost MoR).** Three endpoints the merchant
      calls during the payment lifecycle:
      `POST /api/payments/stripe/payment-intents` (immediate-payment signup),
      `POST /api/payments/stripe/setup-intents` (free-trial signup — card
      saved, no charge), and `POST /api/payments/{paymentId}/refund`
      (refund a renewal payment in full or in part). The browser does the
      actual `confirmPayment` / `confirmSetup` against Stripe; Outpost learns
      the outcome via its own Stripe webhook, so no extra confirmation API
      call is needed from the merchant.
    * **Inbound (Outpost MoR → merchant).** A single webhook endpoint the
      merchant must implement at a URL of its choice. The endpoint receives
      `payment.succeeded`, `payment.failed`, `payment.requires_action`, and
      `payment.refunded` events, all signed with HMAC-SHA256.

    The merchant separately calls Stripe directly for Customer creation,
    Subscription creation, Invoice finalization, and `payment_records`
    attachment on its own Stripe account. Those calls are not part of this
    spec; see the integration guide for the field-level details.

servers:
  - url: https://api.outpostanywhere.com
    description: Production

tags:
  - name: OAuth
    description: Server-to-server access tokens.
  - name: Payments
    description: |
      Payment lifecycle operations — provisioning a SetupIntent at signup,
      confirming the payment after card collection, and refunding in full or
      in part.
  - name: Webhooks
    description: Events Outpost posts to the merchant during renewal cycles.

security:
  - bearerAuth: []

paths:
  /oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange client credentials for an access token
      description: |
        Standard OAuth2 `client_credentials` grant. Tokens are short-lived;
        cache them server-side with `expires_in - 300s` of headroom and refresh
        on `401`. **Never** call this endpoint from a browser.
      operationId: createAccessToken
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
            example:
              grant_type: client_credentials
              client_id: ci_live_abc123
              client_secret: cs_live_xxxxxxxx
              scope: charges.write setup_intents.write
      responses:
        '200':
          description: Token issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
              example:
                access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                token_type: Bearer
                expires_in: 3600
                scope: charges.write setup_intents.write
        '400':
          description: Invalid grant or missing fields.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Bad client credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/payments/stripe/setup-intents:
    post:
      tags: [Payments]
      summary: Create a SetupIntent for payment-method collection on Outpost
      description: |
        Provisions a SetupIntent on Outpost's PSP account with
        `usage=off_session`. The returned `client_secret` is passed to
        **Stripe Elements** in the browser so the customer can confirm payment
        details directly against Stripe — no sensitive payment data ever
        touches the merchant or Outpost servers.

        The set of payment methods offered to the customer is controlled by
        `payment_method_types` on the request body (defaults to `["card"]`).
        Currently supported: `card`, `us_bank_account`, `sepa_debit`,
        `bacs_debit`, `au_becs_debit`. Mandate text for direct-debit methods
        is rendered by Payment Element automatically.

        The PaymentMethod produced by `confirmSetup()` belongs to Outpost's
        PSP account, **not** yours. Outpost is informed of the outcome via
        its own `setup_intent.succeeded` webhook and caches the
        PaymentMethod against `mor_customer_id` for renewals — no
        follow-up Outpost API call is required from the merchant.
      operationId: createSetupIntent
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetupIntentRequest'
            example:
              payment_method_types: [card]
              tax_calculation_id: taxc_a1b2c3d4e5f6789
      responses:
        '200':
          description: SetupIntent created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetupIntentResponse'
              example:
                client_secret: seti_1QwxyzABC_secret_xyzXYZ123
                setup_intent_id: seti_1QwxyzABC
                mor_customer_id: cus_a1b2c3d4e5f6789
                status: requires_payment_method
        '400':
          description: Malformed body, or invalid value in `payment_method_types`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: |
            Merchant account is suspended, or the token lacks the required
            scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The `tax_calculation_id` does not resolve to a known calculation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Same `Idempotency-Key` replayed with a different request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            A `payment_method_types` value is not enabled for your account on
            Outpost's PSP.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Burst exceeded your quota. Retry after backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: |
            Upstream PSP (Stripe) returned 5xx or timed out. Safe to retry
            with the same `Idempotency-Key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/payments/stripe/payment-intents:
    post:
      tags: [Payments]
      summary: Create a PaymentIntent for immediate-payment signup
      description: |
        Used for the **immediate-payment signup variant** — the customer is
        charged at signup (no free trial). Provisions a PaymentIntent on
        Outpost's PSP account with `setup_future_usage=off_session` so the
        same card can be charged off-session for future renewals.

        The browser confirms with `stripe.confirmPayment()` (atomic card
        collection, 3DS, and charge). Outpost learns the outcome via its own
        `payment_intent.succeeded` webhook — no follow-up Outpost API call is
        required from the merchant.

        Works for any Stripe payment method that supports off-session reuse —
        cards, `us_bank_account`, `sepa_debit`, `bacs_debit`, `au_becs_debit`.
        Async-settled methods first transition through `processing` and only
        reach `succeeded` after settlement (days later).
      operationId: createPaymentIntent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PaymentIntentRequest'
            example:
              amount: 1990
              currency: BRL
              merchant_customer:
                stripe_id: cus_QmerchantSide
                email: jordi@example.com.br
                name: Jordi Silva
                country: BR
                address:
                  line1: Av. Paulista 1234
                  city: São Paulo
              description: Pro Monthly subscription — first payment
              tax_calculation_id: taxc_a1b2c3d4e5f6789
      responses:
        '200':
          description: PaymentIntent created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentIntentResponse'
              example:
                client_secret: pi_3QzABC_secret_xyzXYZ123
                payment_intent_id: pi_3QzABC
                mor_customer_id: cus_a1b2c3d4e5f6789
                status: requires_payment_method
        '400':
          description: Missing or malformed amount / currency / merchant_customer.stripe_id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: |
            Merchant account is suspended, or the token lacks the required
            scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The `tax_calculation_id` does not resolve to a known calculation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Same `Idempotency-Key` replayed with a different request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            Currency not enabled on Outpost's PSP, amount below the PSP's
            minimum, or amount above your per-transaction cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Burst exceeded your quota. Retry after backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: |
            Upstream PSP (Stripe) returned 5xx or timed out. Safe to retry
            with the same `Idempotency-Key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/payments/{paymentId}/refund:
    post:
      tags: [Payments]
      summary: Refund a previously confirmed payment
      description: |
        Refunds a payment in full or in part. Outpost executes the refund on
        its PSP account and emits a `payment.refunded` webhook so you can
        update the corresponding PaymentRecord on Stripe.

        Partial refunds are supported — supply `amount` to refund less than
        the full charge. Multiple refunds are allowed until the cumulative
        amount equals the original payment.

        **Idempotency:** the `Idempotency-Key` header is required.
      operationId: refundPayment
      parameters:
        - name: paymentId
          in: path
          required: true
          description: |
            Outpost-owned payment identifier. For renewal payments this is the
            `payment_id` delivered on the `payment.succeeded` webhook. (Refunds
            on signup-time charges are tracked separately through the merchant
            payment record.)
          schema:
            type: string
            examples: [pay_a1b2c3d4e5f6789]
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefundRequest'
            example:
              amount: 990
              reason: requested_by_customer
              metadata:
                merchant_invoice_id: in_1QzABC
      responses:
        '200':
          description: Refund attempted. Inspect `status` for the outcome.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefundResponse'
              example:
                id: rfnd_a1b2c3d4e5f6789
                payment_id: pay_a1b2c3d4e5f6789
                amount: 990
                currency: BRL
                status: succeeded
                reason: requested_by_customer
                processor_references:
                  refund_id: re_3QzABC...
                created_at: '2026-05-12T11:00:00Z'
        '400':
          description: amount exceeds the unrefunded remainder, or currency mismatch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No payment exists for the given paymentId.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            Payment is not in a refundable state (already fully refunded,
            never succeeded, or charged back).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

webhooks:
  payment.succeeded:
    post:
      tags: [Webhooks]
      summary: Renewal payment succeeded
      description: |
        Posted by Outpost to your webhook URL after a successful
        off-session renewal payment. On receipt, the merchant should:

        1. Verify the `Mor-Signature` header (HMAC-SHA256 over `t.payload`,
           keyed with the shared webhook secret).
        2. Call Stripe `POST /v1/payment_records/report_payment` on your
           account with `outcome=guaranteed` and
           `processor_details.custom.payment_reference = processor_charge_id`.
        3. Call Stripe `POST /v1/invoices/{merchant_invoice_id}/attach_payment`
           with the new PaymentRecord — the invoice transitions to `paid`.

        Use deterministic idempotency keys on Stripe (e.g.
        `prpt_{merchant_invoice_id}_{payment_id}`) so retries are safe.
      parameters:
        - $ref: '#/components/parameters/MorSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutboundEvent'
            example:
              id: evt_a1b2c3d4
              type: payment.succeeded
              created: 1747051200
              data:
                object:
                  payment_id: pay_a1b2c3
                  merchant_invoice_id: in_1QzABC
                  merchant_customer_id: cus_QmerchantSide
                  processor_charge_id: ch_3QzABC
                  processor_payment_intent_id: pi_3QzABC
                  amount: 1990
                  currency: BRL
                  status: succeeded
      responses:
        '200':
          description: Acknowledged. Outpost will not retry.
        '4XX':
          description: Outpost gives up after 4xx (treat as permanent failure).
        '5XX':
          description: Outpost retries with exponential backoff.

  payment.failed:
    post:
      tags: [Webhooks]
      summary: Renewal payment failed
      description: |
        Posted when the off-session PaymentIntent on Outpost's PSP account
        ends in `failed`. Leave the merchant invoice open — Stripe Billing's
        Smart Retries will trigger another `invoice.created` on the next attempt,
        which Outpost will pick up and try again.
      parameters:
        - $ref: '#/components/parameters/MorSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutboundEvent'
            example:
              id: evt_failed1
              type: payment.failed
              created: 1747051200
              data:
                object:
                  payment_id: pay_b2c3d4
                  merchant_invoice_id: in_1QzABC
                  merchant_customer_id: cus_QmerchantSide
                  processor_charge_id: ''
                  processor_payment_intent_id: pi_3QzABC
                  amount: 1990
                  currency: BRL
                  status: failed
                  failure_message: Your card was declined.
                  decline_code: insufficient_funds
      responses:
        '200':
          description: Acknowledged. Outpost will not retry.
        '4XX':
          description: Outpost gives up after 4xx (treat as permanent failure).
        '5XX':
          description: Outpost retries with exponential backoff.

  payment.requires_action:
    post:
      tags: [Webhooks]
      summary: Renewal payment requires customer action
      description: |
        Posted when the renewal PaymentIntent requires SCA / 3DS or other
        customer-side action. As with `payment.failed`, leave the invoice open
        and let Stripe Billing's dunning surface the action to the customer.
      parameters:
        - $ref: '#/components/parameters/MorSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutboundEvent'
            example:
              id: evt_action1
              type: payment.requires_action
              created: 1747051200
              data:
                object:
                  payment_id: pay_c3d4e5
                  merchant_invoice_id: in_1QzABC
                  merchant_customer_id: cus_QmerchantSide
                  processor_charge_id: ''
                  processor_payment_intent_id: pi_3QzABC
                  amount: 1990
                  currency: BRL
                  status: requires_action
                  failure_message: Authentication required.
                  decline_code: authentication_required
      responses:
        '200':
          description: Acknowledged. Outpost will not retry.
        '4XX':
          description: Outpost gives up after 4xx (treat as permanent failure).
        '5XX':
          description: Outpost retries with exponential backoff.

  payment.refunded:
    post:
      tags: [Webhooks]
      summary: Payment refunded — full or partial
      description: |
        Fired after every successful refund. Inspect
        `amount_refunded_total` to decide whether the payment is now fully
        or partially refunded, and call
        `POST /v1/payment_records/report_refund` on your Stripe account to
        keep the corresponding PaymentRecord in sync.
      parameters:
        - $ref: '#/components/parameters/MorSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutboundEvent'
            example:
              id: evt_refund1
              type: payment.refunded
              created: 1747051200
              data:
                object:
                  payment_id: pay_a1b2c3
                  merchant_invoice_id: in_1QzABC
                  merchant_customer_id: cus_QmerchantSide
                  refund_id: rfnd_a1b2c3d4e5f6789
                  amount: 990
                  amount_refunded_total: 990
                  currency: BRL
                  status: partially_refunded
                  reason: requested_by_customer
                  processor_refund_id: re_3QzABC
      responses:
        '200':
          description: Acknowledged. Outpost will not retry.
        '4XX':
          description: Outpost gives up after 4xx (treat as permanent failure).
        '5XX':
          description: Outpost retries with exponential backoff.

components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Required for `POST /api/payments/{paymentId}/refund`. Outpost derives
        downstream PSP idempotency keys from this value, so retries with the
        same key are safe. Idempotency on `POST /api/payments/stripe/setup-intents`
        and `POST /api/payments/stripe/payment-intents` is also supported but
        optional.
      schema:
        type: string
        maxLength: 255
        examples: [signup_charge_a1b2c3d4]

    MorSignature:
      name: Mor-Signature
      in: header
      required: true
      description: |
        `t=<unix_seconds>,v1=<hex_hmac_sha256>` where the HMAC is computed over
        the byte string `"<t>.<raw_request_body>"` using the shared webhook
        secret as the key. Reject requests where `t` is too old (5 min
        tolerance recommended) or the signature doesn't match.
      schema:
        type: string
        examples: ['t=1747051200,v1=9b8a7c6d5e4f...']

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Obtain via `POST /oauth/token` with the `client_credentials` grant.
        Send as `Authorization: Bearer <access_token>` on every Outpost API
        endpoint.

  schemas:
    TokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          const: client_credentials
        client_id:
          type: string
          description: Issued by Outpost during onboarding.
        client_secret:
          type: string
          description: Issued by Outpost during onboarding. Server-side only.
        scope:
          type: string
          description: Space-separated scopes.

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token:
          type: string
        token_type:
          type: string
          const: Bearer
        expires_in:
          type: integer
          description: Seconds until expiry. Cache with `expires_in - 300s` headroom.
        scope:
          type: string

    SetupIntentRequest:
      type: object
      description: |
        Optional configuration for the SetupIntent Outpost provisions on its
        PSP account. Defaults to a card-only, off-session SetupIntent if
        the body is empty.
      properties:
        payment_method_types:
          type: array
          description: |
            Stripe payment-method types to enable on the SetupIntent.
            Defaults to `["card"]`. Supported values: `card`,
            `us_bank_account`, `sepa_debit`, `bacs_debit`, `au_becs_debit`.
            Mandate text for direct-debit methods is rendered automatically
            by Payment Element.
          items:
            type: string
            enum: [card, us_bank_account, sepa_debit, bacs_debit, au_becs_debit]
          examples:
            - [card]
        tax_calculation_id:
          type: string
          description: |
            Reference to a tax calculation returned by
            `POST /api/tax/calculate`. Binds the tax breakdown to this
            SetupIntent so it carries through to the first chargeable invoice
            (e.g. at trial end).
          examples: [taxc_a1b2c3d4e5f6789]
      additionalProperties: true

    SetupIntentResponse:
      type: object
      required: [client_secret, setup_intent_id, mor_customer_id, status]
      properties:
        client_secret:
          type: string
          description: |
            Pass to Stripe Elements via
            `loadStripe(OUTPOST_PUBLISHABLE_KEY)` and confirm with
            `stripe.confirmSetup({ clientSecret, … })`.
          examples: [seti_1QwxyzABC_secret_xyzXYZ123]
        setup_intent_id:
          type: string
          description: SetupIntent ID on Outpost's PSP account.
          examples: [seti_1QwxyzABC]
        mor_customer_id:
          type: string
          description: |
            Outpost's Stripe Customer ID (empty Customer with metadata
            pointing back at your `merchant_customer.stripe_id`). Persist for
            future reference.
          examples: [cus_a1b2c3d4e5f6789]
        status:
          type: string
          enum:
            - requires_payment_method
            - requires_confirmation
            - requires_action
            - processing
            - succeeded

    PaymentIntentRequest:
      type: object
      required: [amount, currency, merchant_customer]
      properties:
        amount:
          type: integer
          description: Smallest currency unit (e.g. cents for USD, centavos for BRL).
          minimum: 1
        currency:
          type: string
          minLength: 3
          maxLength: 3
          examples: [BRL]
        merchant_customer:
          $ref: '#/components/schemas/MerchantCustomer'
        description:
          type: string
          description: Free-form description shown on the underlying PaymentIntent.
          maxLength: 255
        tax_calculation_id:
          type: string
          description: |
            Reference to a tax calculation returned by
            `POST /api/tax/calculate`. Binds the calculated tax breakdown to
            this payment.
          examples: [taxc_a1b2c3d4e5f6789]

    PaymentIntentResponse:
      type: object
      required: [client_secret, payment_intent_id, mor_customer_id, status]
      properties:
        client_secret:
          type: string
          description: |
            Pass to Stripe Elements via
            `loadStripe(OUTPOST_PUBLISHABLE_KEY)` and confirm with
            `stripe.confirmPayment({ clientSecret, … })`.
          examples: [pi_3QzABC_secret_xyzXYZ123]
        payment_intent_id:
          type: string
          description: PaymentIntent ID on Outpost's PSP account.
          examples: [pi_3QzABC]
        mor_customer_id:
          type: string
          description: |
            Outpost's Stripe Customer ID (empty Customer with metadata
            pointing back at your `merchant_customer.stripe_id`). Persist for
            future reference.
          examples: [cus_a1b2c3d4e5f6789]
        status:
          type: string
          description: |
            PaymentIntent status — typically `requires_payment_method` at
            this point; the browser transitions it via `confirmPayment()`.
          enum:
            - requires_payment_method
            - requires_confirmation
            - requires_action
            - processing
            - succeeded

    MerchantCustomer:
      type: object
      description: |
        Pointer to the Customer record on **your own Stripe account**.
        Outpost stores this on its empty MoR-side Customer as metadata so the
        renewal flow can map `invoice.created.customer` → saved PaymentMethod.
      required: [stripe_id]
      properties:
        stripe_id:
          type: string
          description: Customer ID on your Stripe account.
          examples: [cus_QmerchantSide]
        email:
          type: string
          description: Customer email. Used for receipt routing and fraud signals.
        name:
          type: string
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
        address:
          type: object
          description: Billing address (line1, city, postal_code, etc.).
          additionalProperties: true

    OutboundEvent:
      type: object
      required: [id, type, created, data]
      properties:
        id:
          type: string
          description: Outpost event ID. Use it to dedupe.
          examples: [evt_a1b2c3d4]
        type:
          type: string
          enum: [payment.succeeded, payment.failed, payment.requires_action, payment.refunded]
        created:
          type: integer
          description: Unix seconds.
        data:
          type: object
          required: [object]
          properties:
            object:
              $ref: '#/components/schemas/OutboundPaymentObject'

    OutboundPaymentObject:
      type: object
      description: |
        Common payload shape for all `payment.*` webhook events. Some fields
        are only present on certain event types — see field-level notes.
      required:
        - payment_id
        - amount
        - currency
        - status
      properties:
        payment_id:
          type: string
          description: Outpost-owned payment identifier (`pay_…`).
        merchant_invoice_id:
          type: string
          description: |
            The Stripe Invoice ID **on your account** that triggered
            this renewal. Pass to `attach_payment` after `report_payment`.
            Present on all `payment.*` events tied to a Stripe Billing
            renewal cycle.
        merchant_customer_id:
          type: string
          description: Customer ID on your Stripe account.
        processor_charge_id:
          type: string
          description: |
            Charge ID on Outpost's PSP account when present. Used as
            `payment_reference` on `payment_records/report_payment`. Empty on
            `failed` / `requires_action`. Not present on `payment.refunded`
            events (see `processor_refund_id` instead).
        processor_payment_intent_id:
          type: string
          description: PaymentIntent ID on Outpost's PSP account.
        amount:
          type: integer
          description: |
            For `payment.succeeded` / `failed` / `requires_action`, the
            payment amount. For `payment.refunded`, the amount refunded by
            this specific event.
        amount_refunded_total:
          type: integer
          description: |
            Cumulative amount refunded across all refunds on this payment.
            Only present on `payment.refunded` events. When equal to the
            original payment amount the payment is fully refunded; otherwise
            partially refunded (see `status`).
        currency:
          type: string
        status:
          type: string
          enum:
            - succeeded
            - failed
            - requires_action
            - refunded
            - partially_refunded
        refund_id:
          type: string
          description: |
            Outpost-owned refund identifier. Only present on
            `payment.refunded` events.
        processor_refund_id:
          type: string
          description: |
            Refund ID on Outpost's PSP account. Only present on
            `payment.refunded` events.
        reason:
          type: string
          description: |
            Refund reason as supplied on the request. Only present on
            `payment.refunded` events.
        failure_message:
          type: string
          description: Human-readable decline reason (present on `failed` / `requires_action`).
        decline_code:
          type: string
          description: PSP decline code (present on `failed` / `requires_action`).

    RefundRequest:
      type: object
      description: |
        Optional body for `POST /api/payments/{paymentId}/refund`. Omit for a
        full refund of the unrefunded remainder.
      properties:
        amount:
          type: integer
          minimum: 1
          description: |
            Smallest currency unit. Defaults to the unrefunded remainder of
            the payment (full refund). Must be ≤ remaining refundable amount.
          examples: [990]
        reason:
          type: string
          description: |
            One of `requested_by_customer`, `duplicate`, `fraudulent`.
            Free-form strings are also accepted and forwarded to the PSP.
          examples: [requested_by_customer]
        metadata:
          type: object
          additionalProperties:
            type: string
          description: |
            Arbitrary key/value pairs persisted on the refund and forwarded to
            the PSP refund object.

    RefundResponse:
      type: object
      required: [id, payment_id, amount, currency, status, processor_references, created_at]
      properties:
        id:
          type: string
          description: Outpost-owned refund identifier.
          examples: [rfnd_a1b2c3d4e5f6789]
        payment_id:
          type: string
          description: The payment this refund applies to.
          examples: [pay_a1b2c3d4e5f6789]
        amount:
          type: integer
        currency:
          type: string
        status:
          type: string
          description: |
            For cards and Link this is synchronous; for bank debits the refund
            starts as `pending` and transitions via the `payment.refunded`
            webhook.
          enum: [succeeded, pending, failed]
        reason:
          type: string
          nullable: true
        processor_references:
          type: object
          properties:
            refund_id:
              type: string
              description: Refund ID on Outpost's PSP account.
              examples: [re_3QzABC]
        created_at:
          type: string
          format: date-time

    Error:
      type: object
      required: [code]
      properties:
        code:
          type: string
          examples: [invalid_request]
        message:
          type: string
