openapi: 3.1.0

info:
  title: PaySaxas XS2A API
  version: "1.0.0"
  description: |
    PaySaxas Oy's PSD2 access-to-account (XS2A) interface, implementing the
    Berlin Group **NextGenPSD2 XS2A Implementation Guidelines v1.3.15**
    (JSON flavour).

    ## What this interface offers

    * **AIS** — account list, balances and transactions, under a consent the
      customer authorised.
    * **PIS** — single SEPA credit transfers (standard and instant), in EUR.
    * **CoF** — a yes/no confirmation that funds are available.

    ## What it does not offer, and why

    Scope follows **portal parity**: a provider gets what the customer gets in
    the PaySaxas portal — no less, and no more. The portal is EUR/SEPA only, so
    the following are outside the interface rather than "not yet built":

    * bulk payments, periodic (standing-order) payments, signing baskets
    * card accounts, multi-currency, SWIFT
    * the embedded and decoupled SCA approaches — **redirect only**
    * QSealC request signing — not required; QWAC mTLS authenticates the channel

    ## Getting access

    There is nothing to apply for. Present a valid eIDAS QWAC carrying your
    PSD2 role attributes and your first request registers you. We do not
    require a contract, charge a fee, or operate an approval queue — RTS
    2018/389 Art. 32(3) forbids it.

    Your authorisation is corroborated against the EBA/NCA register at
    registration and periodically thereafter. If your home regulator withdraws
    your authorisation, access stops.

    ## Authentication and headers

    * **Transport**: mutual TLS with your QWAC, on the XS2A host only.
    * **`X-Request-ID`** (UUID) is required on every request, echoed in the
      response, and is the **idempotency key** for payment initiation.
    * **`Consent-ID`** identifies the consent on account-data and CoF requests.
    * **`PSU-IP-Address`**, when present, marks the request *attended* — the
      customer is at their screen. Its absence makes the request unattended and
      subject to the RTS Art. 36(5) four-per-day cap.
    * **`TPP-Redirect-URI`** / **`TPP-NOK-Redirect-URI`** are bound when an
      authorisation is created and are never re-read afterwards.

    ## Consent lifetime

    Recurring account-information consents last at most **180 days** from the
    customer's authentication (RTS Art. 10a as amended by (EU) 2022/2360).
    Ask for less and we honour it; ask for more and we cap it.

    ## Errors

    Errors use the Berlin Group `tppMessages` envelope. Some refusals are
    deliberately indistinguishable from one another — an unknown consent and
    another provider's consent answer identically, because a difference would
    let you probe for facts no consent covers.

  contact:
    name: PaySaxas TPP support
    email: tpp-support@paysaxas.com
    url: https://paysaxas.com/xs2a
  license:
    name: Proprietary

# SANDBOX IS FIRST, deliberately. A generated client defaults to the first
# entry, so the out-of-the-box behaviour should be the safe one — and every
# page here tells you to start in the sandbox. Listing production first would
# have made "run the generated client" mean "call production".
servers:
  - url: https://xs2a.paysaxas.money
    description: >
      Sandbox — start here. Terminates mutual TLS; test certificates accepted,
      seeded test customers, no real money.
  - url: https://xs2a.paysaxas.com
    description: >
      Production — NOT YET SERVING TRAFFIC. The interface is published ahead of
      go-live under RTS Art. 30(5); this host does not answer yet, and the
      go-live date will be announced in the changelog. Integrate against the
      sandbox above. When it opens it terminates mutual TLS, and your eIDAS
      QWAC must be presented on every request, including the ones that fail.

# Applies to every operation. There is no unauthenticated path and no
# per-operation exception: a request without a client certificate is refused
# with CERTIFICATE_MISSING whatever else it contains.
security:
  - qwacMutualTLS: []

tags:
  - name: Consents
    description: Account-information and funds-confirmation consents
  - name: Accounts
    description: Account list, balances and transactions (AIS)
  - name: Payments
    description: SEPA credit transfer initiation (PIS)
  - name: Funds confirmations
    description: Confirmation of funds (CBPII)

paths:
  /v1/consents/confirmation-of-funds:
    post:
      tags: [Consents]
      summary: Create a confirmation-of-funds consent (CBPII)
      description: |
        The consent `POST /v1/funds-confirmations` requires. It is a separate
        resource from `POST /v1/consents`, which creates account-information
        consents only — an AIS consent is rejected by the funds-confirmation
        service with `CONSENT_UNKNOWN`.

        Names exactly one account and grants no data access: the service
        answers a boolean and nothing else. Authorisation is the same implicit
        redirect flow as an AIS consent — follow `_links.scaRedirect`.
      operationId: createFundsConfirmationConsent
      parameters:
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/TppRedirectURI"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FundsConfirmationConsentRequest" }
      responses:
        "201":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Consent created, awaiting the customer's authorisation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsentCreationResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/confirmation-of-funds/{consent_id}:
    get:
      tags: [Consents]
      summary: Read a confirmation-of-funds consent
      operationId: getFundsConfirmationConsent
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: The consent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsentResponse" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Consents]
      summary: Revoke a confirmation-of-funds consent
      operationId: deleteFundsConfirmationConsent
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "204":
          description: Revoked
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/confirmation-of-funds/{consent_id}/status:
    get:
      tags: [Consents]
      summary: Confirmation-of-funds consent status
      operationId: getFundsConfirmationConsentStatus
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Current consent status
          content:
            application/json:
              schema:
                type: object
                required: [consentStatus]
                properties:
                  consentStatus: { $ref: "#/components/schemas/ConsentStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents:
    post:
      tags: [Consents]
      summary: Create a consent
      description: |
        Returns a `consentId` and a `scaRedirect` link. The consent does not
        exist yet — the customer must authenticate on the PaySaxas surface
        first. Until then, `GET .../status` reports `received`.

        Requested IBANs are format-checked but **not** looked up: no customer
        has authenticated at this point, so resolving them would answer
        questions about accounts no consent covers. Ownership is settled when
        the customer authorises, and an account they do not hold is simply not
        granted.
      operationId: createConsent
      parameters:
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/TppRedirectURI"
        - $ref: "#/components/parameters/TppNokRedirectURI"
        - $ref: "#/components/parameters/PsuIpAddress"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConsentRequest" }
      responses:
        "201":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Consent resource created, awaiting the customer's authentication
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsentCreationResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/{consent_id}:
    get:
      tags: [Consents]
      summary: Read a consent
      operationId: getConsent
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: The consent as the customer authorised it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsentResponse" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Consents]
      summary: Terminate a consent
      description: |
        Ends a consent you hold. Takes effect immediately; no notification is
        sent back to you, since you are the party asking.
      operationId: deleteConsent
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "204":
          description: Consent terminated
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/{consent_id}/status:
    get:
      tags: [Consents]
      summary: Consent status
      operationId: getConsentStatus
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Current consent status
          content:
            application/json:
              schema:
                type: object
                required: [consentStatus]
                properties:
                  consentStatus: { $ref: "#/components/schemas/ConsentStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/{consent_id}/authorisations:
    get:
      tags: [Consents]
      summary: Authorisation sub-resources for a consent
      description: |
        Returns the authorisation ids belonging to this consent, so the
        polling operation below can be addressed without parsing an `_links`
        href. A consent is authorised once, so this returns zero or one id.
      operationId: getConsentAuthorisations
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Authorisation ids
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Authorisations" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/consents/{consent_id}/authorisations/{authorisation_id}:
    get:
      tags: [Consents]
      summary: Authorisation (SCA) status for a consent
      description: |
        Only the **implicit** authorisation start is offered: `POST /v1/consents`
        already returns `scaRedirect`, so there is no separate authorisation to
        create. This sub-resource exists for polling.
      operationId: getConsentAuthorisation
      parameters:
        - $ref: "#/components/parameters/ConsentIdPath"
        - $ref: "#/components/parameters/AuthorisationIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Current SCA status
          content:
            application/json:
              schema:
                type: object
                required: [scaStatus]
                properties:
                  scaStatus: { $ref: "#/components/schemas/ScaStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/accounts:
    get:
      tags: [Accounts]
      summary: List consented accounts
      operationId: getAccounts
      parameters:
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/ConsentIdHeader"
        - $ref: "#/components/parameters/PsuIpAddress"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          description: The accounts covered by the consent
          content:
            application/json:
              schema:
                type: object
                required: [accounts]
                properties:
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/AccountDetails" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/AccessExceeded" }

  /v1/accounts/{account_id}:
    get:
      tags: [Accounts]
      summary: Read one account
      operationId: getAccount
      parameters:
        - $ref: "#/components/parameters/AccountIdPath"
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/ConsentIdHeader"
        - $ref: "#/components/parameters/PsuIpAddress"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          description: Account details
          content:
            application/json:
              schema:
                type: object
                required: [account]
                properties:
                  account: { $ref: "#/components/schemas/AccountDetails" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/AccessExceeded" }

  /v1/accounts/{account_id}/balances:
    get:
      tags: [Accounts]
      summary: Account balances
      operationId: getBalances
      parameters:
        - $ref: "#/components/parameters/AccountIdPath"
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/ConsentIdHeader"
        - $ref: "#/components/parameters/PsuIpAddress"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          description: Booked and available balances
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BalanceResponse" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/AccessExceeded" }

  /v1/accounts/{account_id}/transactions:
    get:
      tags: [Accounts]
      summary: Account transactions
      description: |
        Returns at most **250 transactions per page**, and pages with a keyset
        cursor rather than an offset — so a payment arriving mid-traversal
        cannot shift a page boundary and cause a duplicate or a skip.

        To continue, either follow `_links.next` (it carries every filter from
        your original request) or pass the last row's `entryReference` back as
        `entryReferenceFrom`. They are the same mechanism: our cursor IS the
        Berlin Group entry identification.

        **`_links.next` is present only when more history exists**, so its
        absence is a definite end. A full page of 250 is not itself a signal —
        do not infer "more" from the count, and do not poll for a link that
        will not appear.
      operationId: getTransactions
      x-page-limit: 250
      parameters:
        - $ref: "#/components/parameters/AccountIdPath"
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/ConsentIdHeader"
        - $ref: "#/components/parameters/PsuIpAddress"
        - name: dateFrom
          in: query
          schema: { type: string, format: date }
        - name: dateTo
          in: query
          schema: { type: string, format: date }
        - name: entryReferenceFrom
          in: query
          required: false
          description: >
            Return only transactions AFTER the one carrying this
            `entryReference`. The cursor for paging; prefer following
            `_links.next`, which carries it for you along with every other
            filter.
          schema: { type: string }
        - name: bookingStatus
          in: query
          schema: { type: string, enum: [booked, pending, both] }
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          description: Transactions for the account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransactionsResponse" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/AccessExceeded" }

  /v1/payments/{payment_product}:
    post:
      tags: [Payments]
      summary: Initiate a SEPA credit transfer
      description: |
        Creates the payment and returns a `scaRedirect` link. The customer
        authorises on the PaySaxas surface with **dynamic linking** — the
        ceremony is bound to this amount and this payee.

        `X-Request-ID` is the idempotency key: replaying it returns the first
        payment rather than initiating a second.

        `creditorAccount.bic` is optional; we resolve it ourselves when we can.
      operationId: initiatePayment
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/TppRedirectURI"
        - $ref: "#/components/parameters/TppNokRedirectURI"
        - $ref: "#/components/parameters/PsuIpAddress"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentInitiationRequest" }
      responses:
        "201":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Payment created, awaiting the customer's authorisation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentInitiationResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/payments/{payment_product}/{payment_id}:
    get:
      tags: [Payments]
      summary: Read a payment
      operationId: getPayment
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/PaymentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: The payment as initiated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentResponse" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Payments]
      summary: Cancel a payment
      description: |
        Possible only while the payment could still be cancelled in the
        customer's own portal. Past that point the funds have moved and the
        answer is `CANCELLATION_INVALID`.
      operationId: cancelPayment
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/PaymentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Payment cancelled
          content:
            application/json:
              schema:
                type: object
                required: [transactionStatus]
                properties:
                  transactionStatus: { $ref: "#/components/schemas/TransactionStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "405": { $ref: "#/components/responses/CancellationInvalid" }

  /v1/payments/{payment_product}/{payment_id}/status:
    get:
      tags: [Payments]
      summary: Payment status
      operationId: getPaymentStatus
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/PaymentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Current transaction status
          content:
            application/json:
              schema:
                type: object
                required: [transactionStatus]
                properties:
                  transactionStatus: { $ref: "#/components/schemas/TransactionStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/payments/{payment_product}/{payment_id}/authorisations:
    get:
      tags: [Payments]
      summary: Authorisation sub-resources for a payment
      description: |
        Returns the authorisation ids belonging to this payment, scoped to
        the calling provider.
      operationId: getPaymentAuthorisations
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/PaymentIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Authorisation ids
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Authorisations" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/payments/{payment_product}/{payment_id}/authorisations/{authorisation_id}:
    get:
      tags: [Payments]
      summary: Authorisation (SCA) status for a payment
      operationId: getPaymentAuthorisation
      parameters:
        - $ref: "#/components/parameters/PaymentProductPath"
        - $ref: "#/components/parameters/PaymentIdPath"
        - $ref: "#/components/parameters/AuthorisationIdPath"
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Current SCA status
          content:
            application/json:
              schema:
                type: object
                required: [scaStatus]
                properties:
                  scaStatus: { $ref: "#/components/schemas/ScaStatus" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/revocation-callback:
    get:
      tags: [Consents]
      summary: Read your registered revocation callback
      description: >
        Returns the callback **and the signing secret** — the same body the PUT
        returns. The secret is derived from your registration rather than
        stored, so this is a re-derivation, not a retrieval of something we
        kept: if you lose it, read it here. There is no reset ceremony because
        there is nothing to reset.

        The practical consequence, stated because getting it wrong is silent:
        you do not have to persist the secret across deploys. A process that
        comes up without it can GET here and carry on verifying callbacks.

        404 when you have registered no callback — that is the normal state,
        not an error condition to alert on.
      operationId: getRevocationCallback
      parameters:
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: The registered callback and how to verify it
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RevocationCallback" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Consents]
      summary: Register or replace your revocation callback
      description: >
        Self-service; no support ticket. Declares the desired state, so a repeat
        with the same URL is a no-op and a repeat with a different one replaces
        it. Returns your signing secret — derived from your registration rather
        than stored, so re-reading it here recovers it without a reset.

        Available to any registered provider whatever its certificate
        authorises: managing your own notifications is not an AIS, PIS or CBPII
        service.
      operationId: putRevocationCallback
      parameters:
        - $ref: "#/components/parameters/XRequestID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Absolute https URI, no fragment.
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
          description: Callback registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RevocationCallback" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Consents]
      summary: Stop receiving revocation callbacks
      operationId: deleteRevocationCallback
      parameters:
        - $ref: "#/components/parameters/XRequestID"
      responses:
        "204":
          description: Callback removed
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/funds-confirmations:
    post:
      tags: [Funds confirmations]
      summary: Confirm funds are available
      description: |
        Answers `true` or `false` under a `cbpii` consent. It returns **only**
        that boolean — never the balance, the shortfall, or the account's
        status. An account-information consent cannot be used here.
      operationId: confirmFunds
      parameters:
        - $ref: "#/components/parameters/XRequestID"
        - $ref: "#/components/parameters/ConsentIdHeader"
        - $ref: "#/components/parameters/PsuIpAddress"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FundsConfirmationRequest" }
      responses:
        "200":
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestID" }
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          description: Whether the funds are available
          content:
            application/json:
              schema:
                type: object
                required: [fundsAvailable]
                properties:
                  fundsAvailable: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorised" }
        "500": { $ref: "#/components/responses/ServerError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/AccessExceeded" }

components:
  securitySchemes:
    qwacMutualTLS:
      type: mutualTLS
      description: >
        Client-certificate authentication with an eIDAS QWAC carrying your PSD2
        role attributes (ETSI TS 119 495). The certificate IS the credential —
        there is no API key, token or secret to configure, and nothing to apply
        for. Configure a generated client with your certificate and private key
        at the TRANSPORT layer; without them every call returns
        CERTIFICATE_MISSING.

  parameters:
    XRequestID:
      name: X-Request-ID
      in: header
      required: true
      description: UUID. Echoed in the response; the idempotency key for payment initiation.
      schema: { type: string, format: uuid }
    ConsentIdHeader:
      name: Consent-ID
      in: header
      required: true
      schema: { type: string }
    ConsentIdPath:
      name: consent_id
      in: path
      required: true
      schema: { type: string }
    AuthorisationIdPath:
      name: authorisation_id
      in: path
      required: true
      schema: { type: string }
    AccountIdPath:
      name: account_id
      in: path
      required: true
      description: >
        The `resourceId` from `GET /v1/accounts`. Do not construct it and do not
        substitute the IBAN — an IBAN in this position is not recognised.
      schema: { type: string }
    PaymentIdPath:
      name: payment_id
      in: path
      required: true
      schema: { type: string }
    PaymentProductPath:
      name: payment_product
      in: path
      required: true
      schema:
        type: string
        enum: [sepa-credit-transfers, instant-sepa-credit-transfers]
    PsuIpAddress:
      name: PSU-IP-Address
      in: header
      required: false
      description: >
        Present when the customer is at their screen. Its absence makes the
        request unattended and subject to the four-per-day cap.
      schema: { type: string }
    TppRedirectURI:
      name: TPP-Redirect-URI
      in: header
      required: true
      description: >
        Absolute https URI, no fragment. REQUIRED on consent and payment
        creation: SCA is redirect-only, so an authorisation with no return
        address strands the customer on our confirmation page. Bound at
        creation and never re-read.
      schema: { type: string, format: uri }
    TppNokRedirectURI:
      name: TPP-NOK-Redirect-URI
      in: header
      required: false
      schema: { type: string, format: uri }

  headers:
    XRequestID:
      description: >
        Echo of the `X-Request-ID` you sent. Present on EVERY response,
        including errors — quote it to support and we can find the exact
        request in the traceability log.
      schema: { type: string }
    RateLimitLimit:
      description: Unattended accesses allowed per data type per day.
      schema: { type: integer }
    RateLimitRemaining:
      description: >
        Unattended accesses left for THIS data type today. Attended requests
        report it without consuming it.
      schema: { type: integer }
    RateLimitReset:
      description: Unix timestamp when the budget resets.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying. Sent with 429.
      schema: { type: integer }
  responses:
    BadRequest:
      description: Malformed or unacceptable request
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    Unauthorised:
      description: Certificate or consent problem
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    Forbidden:
      description: >
        Role, registration or consent does not permit this request.

        On the consent operations this status also SUBSUMES "not found": an
        unknown consent id and another provider's consent both answer
        403 CONSENT_UNKNOWN, identically, because a distinguishable 404 would
        let you probe which ids exist. Those operations therefore declare no
        404 — do not write a 404 branch for them, it is unreachable.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    NotFound:
      description: No such resource for this provider
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    CancellationInvalid:
      description: The payment can no longer be cancelled
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    ServerError:
      description: >
        We broke. Unlike every 4xx here, this says nothing about your request —
        replaying it unchanged is the correct response, after a backoff.

        **5xx and connection failures are the only retryable classes.** A 4xx
        means the interface understood you and said no; retrying it produces
        the same refusal and burns your access budget.

        The body is BEST EFFORT. The application emits the usual `tppMessages`
        envelope with code `INTERNAL_SERVER_ERROR`, but a failure above it —
        load balancer, TLS termination, a timeout that never reaches the
        application — produces whatever that layer emits, which may be HTML or
        empty. Branch on the status code; never require the body to parse.

        Also covers 502, 503 and 504, which carry the same meaning.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }
    AccessExceeded:
      description: >
        Unattended daily access limit reached (RTS Art. 36(5)). Do not retry
        before `Retry-After` — the budget is per calendar day and resets at
        midnight UTC, so an immediate retry cannot succeed.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestID" }
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        X-RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/TppMessages" }

  schemas:
    TppMessages:
      type: object
      examples:
        - tppMessages:
            - category: ERROR
              code: FORMAT_ERROR
              path: creditorAccount.iban
              text: "creditorAccount.iban failed its checksum."
      required: [tppMessages]
      properties:
        tppMessages:
          type: array
          items:
            type: object
            # `code` is the stable contract the catalogue tells you to branch
            # on; without `required` a strict generator models it optional and
            # you end up handling a case that cannot occur. `category` is always
            # set too. `path` and `text` really are optional — see their notes.
            required: [category, code]
            properties:
              category: { type: string, enum: [ERROR, WARNING] }
              code: { type: string }
              path:
                type: string
                description: >
                  Dot-notated location of the offending field in YOUR request
                  body, e.g. `creditorAccount.iban` or
                  `instructedAmount.amount`. Present when a specific field is
                  at fault; absent when the refusal is not about one (a
                  compliance outcome, a header problem, a state conflict).
                  This is the machine-readable half of an error — branch on
                  `code` and `path`, never on `text`.
                examples: ["creditorAccount.iban"]
              text:
                type: string
                description: >
                  Human-readable, for your logs and support tickets. The
                  wording may change without notice; do not parse it.

    ConsentStatus:
      type: string
      enum: [received, valid, expired, revokedByPsu, terminatedByTpp, rejected]

    ScaStatus:
      type: string
      enum: [received, started, psuAuthenticated, finalised, failed]

    TransactionStatus:
      type: string
      enum: [RCVD, ACTC, PDNG, ACSP, ACSC, RJCT, CANC]

    AccountReference:
      type: object
      required: [iban]
      properties:
        iban:
          type: string
          pattern: "^[A-Z]{2}[0-9]{2}[A-Z0-9]{4,30}$"
          description: >
            ISO 13616. The pattern is NECESSARY BUT NOT SUFFICIENT: we also
            check the country's declared length and the MOD 97-10 checksum,
            neither of which a regular expression can express. Validate the
            shape locally to catch typos without a round trip; expect
            FORMAT_ERROR to remain possible for a well-shaped IBAN whose
            checksum fails.
          examples: ["FI2112345600000785"]
        currency: { type: string, enum: [EUR] }

    Amount:
      type: object
      required: [currency, amount]
      properties:
        currency: { type: string, enum: [EUR] }
        amount:
          type: string
          pattern: '^[0-9]+(\.[0-9]{1,2})?$'
          description: >
            A decimal STRING, never a JSON number — a float cannot represent
            money exactly. Two decimal places at most, no thousands separator,
            no sign, and it must be greater than zero (the pattern admits "0",
            which we reject with FORMAT_ERROR).
          examples: ["125.50"]

    SignedAmount:
      type: object
      required: [currency, amount]
      properties:
        currency: { type: string, enum: [EUR] }
        amount:
          type: string
          pattern: '^-?[0-9]+(\.[0-9]{1,2})?$'
          description: >
            A decimal STRING, never a JSON number. Unlike `Amount`, this one
            is SIGNED: Berlin Group expresses direction by sign on reported
            amounts, so a debit is negative and a credit positive. Do not
            take the absolute value — the sign is the direction, and there is
            no other field carrying it.
          examples: ["-125.50"]

    ConsentRequest:
      type: object
      examples:
        - access:
            balances:
              - iban: "FI2112345600000785"
            transactions:
              - iban: "FI2112345600000785"
          recurringIndicator: true
          validUntil: "2027-01-30"
          frequencyPerDay: 4
      required: [access]
      properties:
        access:
          type: object
          description: >
            Dedicated accounts only. `availableAccounts`,
            `availableAccountsWithBalance` and `allPsd2` are refused explicitly
            rather than silently narrowed.
          properties:
            accounts:
              type: array
              items: { $ref: "#/components/schemas/AccountReference" }
            balances:
              type: array
              items: { $ref: "#/components/schemas/AccountReference" }
            transactions:
              type: array
              items: { $ref: "#/components/schemas/AccountReference" }
        recurringIndicator: { type: boolean }
        validUntil:
          type: string
          format: date
          description: Capped at 180 days from the customer's authentication.
        frequencyPerDay:
          type: integer
          default: 4
          description: >
            Unattended accesses per data type per day. Requesting more than 4
            is CAPPED, not refused — like `validUntil`. Requesting fewer does
            not reduce it. The granted consent reports what is enforced, which
            is 4 for a recurring consent and 1 for a one-off.
        combinedServiceIndicator:
          type: boolean
          description: >
            NOT OFFERED. Consents and payments are separate resources here, so
            there is no combined AIS+PIS session. Sending `true` is refused with
            SERVICE_INVALID rather than silently granting an ordinary consent
            you would then use wrongly. Omit it, or send `false`.

    FundsConfirmationConsentRequest:
      type: object
      examples:
        - account:
            iban: "FI2112345600000785"
      required: [account]
      properties:
        account: { $ref: "#/components/schemas/AccountReference" }
        # Optional. Only the last four digits are retained — a PAN is
        # payment-instrument data we have no need to hold.
        cardNumber: { type: string }
        cardExpiryDate: { type: string, format: date }

    ConsentCreationResponse:
      type: object
      examples:
        - consentId: "c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34"
          consentStatus: received
          authorisationId: "7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41"
          _links:
            scaRedirect:
              href: "https://platform.paysaxas.com/customer/xs2a/authorise/7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41?state=5e9c1f84-2b7d-4a63-8e05-1c9f3d7b6a28"
            self:
              href: "/v1/consents/c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34"
            status:
              href: "/v1/consents/c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34/status"
            scaStatus:
              href: "/v1/consents/c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34/authorisations/7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41"
      required: [consentId, consentStatus, authorisationId, _links]
      properties:
        consentId: { type: string }
        consentStatus: { $ref: "#/components/schemas/ConsentStatus" }
        # Address the authorisation sub-resource without parsing a link.
        authorisationId: { type: string }
        _links: { $ref: "#/components/schemas/Links" }

    ConsentResponse:
      type: object
      examples:
        - consentId: "c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34"
          consentStatus: valid
          recurringIndicator: true
          validUntil: "2027-01-30"
          frequencyPerDay: 4
          lastActionDate: "2026-08-03"
          access:
            balances:
              - iban: "FI2112345600000785"
            transactions:
              - iban: "FI2112345600000785"
          _links:
            self:
              href: "/v1/consents/c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34"
            status:
              href: "/v1/consents/c7d41a8e-3b26-4f95-9e01-5a8d2c6f7b34/status"
      required: [consentId, consentStatus]
      properties:
        consentId: { type: string }
        consentStatus: { $ref: "#/components/schemas/ConsentStatus" }
        recurringIndicator: { type: boolean }
        validUntil: { type: string, format: date }
        frequencyPerDay:
          type: integer
          description: >
            What is ENFORCED, per data type per day — not what was requested.
            4 on a recurring consent, 1 on a one-off.
        lastActionDate: { type: string, format: date }
        _links: { $ref: "#/components/schemas/Links" }
        # What was actually GRANTED, which may be narrower than what was asked
        # for. Present on account-information consents.
        access: { $ref: "#/components/schemas/AccountAccess" }
        # Present INSTEAD of `access` on a confirmation-of-funds consent: it is
        # not a data-access grant, only the account the confirmation is about.
        account: { $ref: "#/components/schemas/AccountReference" }

    AccountAccess:
      type: object
      # NO `required`, deliberately: an absent or empty list means that data type
      # was NOT granted. Requiring any of them would contradict the read-back
      # semantics above — a narrowed grant is a normal outcome, not a defect.
      examples:
        # `accounts` is absent here on purpose: the customer granted balances
        # and transactions but not the account list. Read the grant back.
        - balances:
            - iban: "FI2112345600000785"
          transactions:
            - iban: "FI2112345600000785"
      description: >
        The data types granted, stated per list. An omitted or empty list means
        that data type was NOT granted — read it back rather than assuming the
        grant matches the request, because the customer may narrow it.
      properties:
        accounts:
          type: array
          description: Accounts whose details are readable.
          items: { $ref: "#/components/schemas/AccountReference" }
        balances:
          type: array
          description: Accounts whose balances are readable.
          items: { $ref: "#/components/schemas/AccountReference" }
        transactions:
          type: array
          description: Accounts whose transactions are readable.
          items: { $ref: "#/components/schemas/AccountReference" }

    AccountDetails:
      type: object
      examples:
        - resourceId: "3f2a9c81-5d47-4c0e-9a1b-77e6c2f0d913"
          iban: "FI2112345600000785"
          currency: EUR
          name: "Operating account"
          product: "Business current account"
          cashAccountType: CACC
      required: [resourceId, iban, currency]
      description: >
        Deliberately narrow. The account's institutional status is not carried:
        a provider sees what the customer sees, never how we are treating the
        account.
      properties:
        resourceId:
          type: string
          description: >
            Berlin Group's identifier for addressing this account. Use it as the
            `{account_id}` path parameter on `/v1/accounts/{account_id}`,
            `…/balances` and `…/transactions`. It is opaque and stable for the
            life of the account; it is NOT the IBAN.
        iban: { type: string }
        currency: { type: string, enum: [EUR] }
        name: { type: string }
        product: { type: string }
        cashAccountType: { type: string, examples: [CACC] }

    BalanceResponse:
      type: object
      examples:
        - account:
            iban: "FI2112345600000785"
          balances:
            - balanceType: closingBooked
              balanceAmount: { currency: EUR, amount: "1840.25" }
              lastChangeDateTime: "2026-08-03T09:14:22Z"
            - balanceType: interimAvailable
              balanceAmount: { currency: EUR, amount: "1715.25" }
              lastChangeDateTime: "2026-08-03T09:14:22Z"
      required: [account, balances]
      properties:
        account: { $ref: "#/components/schemas/AccountReference" }
        balances:
          type: array
          items:
            type: object
            properties:
              balanceType: { type: string, enum: [closingBooked, interimAvailable] }
              balanceAmount: { $ref: "#/components/schemas/SignedAmount" }
              lastChangeDateTime: { type: string, format: date-time }

    TransactionsResponse:
      type: object
      examples:
        - account:
            iban: "FI2112345600000785"
          transactions:
            booked:
              - transactionId: "8f14e45f-ceea-467a-9575-3b7c1a2d6e04"
                entryReference: "MjAyNi0wOC0wMnw4ZjE0ZTQ1Zg"
                bookingDate: "2026-08-02"
                valueDate: "2026-08-02"
                transactionAmount: { currency: EUR, amount: "-125.50" }
                creditorName: "Nordic Supplies Oy"
            _links:
              next:
                href: "/v1/accounts/3f2a9c81-5d47-4c0e-9a1b-77e6c2f0d913/transactions?entryReferenceFrom=MjAyNi0wOC0wMnw4ZjE0ZTQ1Zg"
      required: [account, transactions]
      properties:
        account: { $ref: "#/components/schemas/AccountReference" }
        transactions:
          type: object
          description: >
            Which arrays are present follows the `bookingStatus` query
            parameter. A payment that was rejected or cancelled appears in
            NEITHER array — no money moved, so there is nothing on the account
            to report.
          properties:
            booked:
              type: array
              description: >
                Money has moved. Present when `bookingStatus` is `booked`
                (the default) or `both`.
              items: { $ref: "#/components/schemas/Transaction" }
            pending:
              type: array
              description: >
                Authorised but not yet booked — it may still fail. Do not
                reconcile against these. Present when `bookingStatus` is
                `pending` or `both`.
              items: { $ref: "#/components/schemas/Transaction" }
            _links: { $ref: "#/components/schemas/Links" }

    Transaction:
      type: object
      examples:
        - transactionId: "8f14e45f-ceea-467a-9575-3b7c1a2d6e04"
          entryReference: "MjAyNi0wOC0wMnw4ZjE0ZTQ1Zg"
          bookingDate: "2026-08-02"
          valueDate: "2026-08-02"
          transactionAmount: { currency: EUR, amount: "-125.50" }
          creditorName: "Nordic Supplies Oy"
      required: [transactionId, entryReference, transactionAmount]
      properties:
        transactionId: { type: string }
        entryReference:
          type: string
          description: >
            Opaque identification of this row's position in the history. Pass it
            back as `entryReferenceFrom` to continue from here.
        bookingDate: { type: string, format: date }
        valueDate: { type: string, format: date }
        transactionAmount: { $ref: "#/components/schemas/SignedAmount" }
        creditorName: { type: string }
        debtorName: { type: string }

    PaymentInitiationRequest:
      type: object
      examples:
        - debtorAccount: { iban: "FI2112345600000785" }
          creditorAccount: { iban: "DE89370400440532013000" }
          creditorName: "Nordic Supplies Oy"
          instructedAmount: { currency: EUR, amount: "125.50" }
          remittanceInformationUnstructured: "Invoice 2026-0417"
      required: [debtorAccount, instructedAmount, creditorAccount]
      properties:
        debtorAccount: { $ref: "#/components/schemas/AccountReference" }
        instructedAmount: { $ref: "#/components/schemas/Amount" }
        creditorAccount:
          allOf:
            - $ref: "#/components/schemas/AccountReference"
            - type: object
              properties:
                bic: { type: string }
        creditorName: { type: string }
        remittanceInformationUnstructured: { type: string }
        endToEndIdentification: { type: string }
        requestedExecutionDate: { type: string, format: date }

    PaymentInitiationResponse:
      type: object
      examples:
        - transactionStatus: RCVD
          paymentId: "b3d4c2e1-9a76-4f58-8c31-2e5d7a0f4b19"
          authorisationId: "7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41"
          _links:
            scaRedirect:
              href: "https://platform.paysaxas.com/customer/xs2a/authorise/7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41?state=5e9c1f84-2b7d-4a63-8e05-1c9f3d7b6a28"
            status:
              href: "/v1/payments/sepa-credit-transfers/b3d4c2e1-9a76-4f58-8c31-2e5d7a0f4b19/status"
      required: [transactionStatus, paymentId, authorisationId, _links]
      properties:
        transactionStatus: { $ref: "#/components/schemas/TransactionStatus" }
        paymentId: { type: string }
        # Address the authorisation sub-resource without parsing a link.
        authorisationId: { type: string }
        _links: { $ref: "#/components/schemas/Links" }

    PaymentResponse:
      type: object
      examples:
        - transactionStatus: ACSC
          debtorAccount: { iban: "FI2112345600000785" }
          creditorAccount: { iban: "DE89370400440532013000" }
          creditorName: "Nordic Supplies Oy"
          instructedAmount: { currency: EUR, amount: "125.50" }
          remittanceInformationUnstructured: "Invoice 2026-0417"
      required: [transactionStatus, debtorAccount, creditorAccount, instructedAmount]
      properties:
        transactionStatus: { $ref: "#/components/schemas/TransactionStatus" }
        debtorAccount: { $ref: "#/components/schemas/AccountReference" }
        creditorAccount: { $ref: "#/components/schemas/AccountReference" }
        creditorName: { type: string }
        instructedAmount: { $ref: "#/components/schemas/Amount" }
        remittanceInformationUnstructured: { type: string }

    FundsConfirmationRequest:
      type: object
      examples:
        - account: { iban: "FI2112345600000785" }
          instructedAmount: { currency: EUR, amount: "125.50" }
      required: [account, instructedAmount]
      properties:
        account: { $ref: "#/components/schemas/AccountReference" }
        instructedAmount: { $ref: "#/components/schemas/Amount" }
        cardNumber:
          type: string
          description: >
            Optional and INFORMATIONAL. It is not matched against the consent
            and does not scope the answer — the consent names an account, and
            the answer is about that account's funds. We do not store it.

    RevocationCallback:
      type: object
      examples:
        - url: "https://tpp.example/psd2/revocation"
          signatureHeader: X-PaySaxas-Signature
          signatureToleranceSeconds: 300
          # On the GET as well as the PUT: derived from the registration, so
          # re-reading recovers it. Only ever to the certificate that owns it.
          secret: "<32-byte hex>"
      required: [url, signatureHeader, signatureToleranceSeconds]
      properties:
        url: { type: string, format: uri }
        signatureHeader:
          type: string
          description: The header carrying the HMAC signature on each callback.
        signatureToleranceSeconds:
          type: integer
          description: Reject a signature older than this.
        secret:
          type: string
          description: >
            Your HMAC key, returned by BOTH `PUT` and `GET` and only ever to the
            holder of the certificate that owns this registration. Derived from
            the registration rather than stored, so reading it again recovers
            the same value — losing it is not an incident and needs no reset.

    Authorisations:
      type: object
      examples:
        - authorisationIds: ["7c2e1b95-4a83-4d16-9f52-0e8b3c6d7a41"]
      required: [authorisationIds]
      properties:
        authorisationIds:
          type: array
          items: { type: string }

    Links:
      type: object
      # NO `required`, deliberately: which members appear varies by response.
      # A consent creation carries `scaRedirect`; a consent read does not. `next`
      # is the sharpest case — its ABSENCE is the end-of-history signal, so
      # requiring it would invert the contract.
      properties:
        scaRedirect:
          type: object
          required: [href]
          properties:
            href: { type: string }
        scaStatus:
          type: object
          required: [href]
          properties:
            href: { type: string }
        self:
          type: object
          required: [href]
          properties:
            href: { type: string }
        status:
          type: object
          required: [href]
          properties:
            href: { type: string }
        account:
          type: object
          required: [href]
          properties:
            href: { type: string }
        next:
          type: object
          description: >
            The next page of a transaction list. PRESENT ONLY when more history
            exists, so its absence is a definite end — never poll for it. The
            href carries every filter from the original request; follow it
            rather than constructing your own.
          properties:
            href: { type: string }
