# GENERATED FILE — public Lender OS AA Orchestrator API spec.
# Produced by scripts/scrub_openapi.py (orchestrator repo) from the internal
# docs/openapi.yaml. Do not hand-edit: CI overwrites this file.
openapi: 3.1.0

info:
  title: Lender OS AA Orchestrator API
  version: 1.0.0
  summary: One API and one consent journey for consented financial data across RBI-licensed Account Aggregators.
  description: |
    The Lender OS AA Orchestrator gives a Financial Information User (FIU) a single integration for the
    Account Aggregator framework: compile a consent, launch the borrower's consent journey, acquire
    Financial Information (FI) within the consent's terms, and pull the raw payload — across every AA
    the tenant has contracted, without any AA-specific or wrapper-specific concept ever reaching this API.

    Lender OS is the Technology Service Provider (TSP). **You are the FIU.** Your FIU identity, keys and AA
    contracts are yours; the orchestrator operates them on your behalf.

    ## Environments

    | Environment | Base URL | API key prefix | `livemode` |
    |---|---|---|---|
    | Sandbox | `https://api.sandbox.lenderos.in/v1` | `los_sk_test_` | `false` |
    | Production | `https://api.lenderos.in/v1` | `los_sk_live_` | `true` |

    Sandbox and production are fully separate deployments: separate keys, separate connector credentials,
    separate data plane. A key presented to the wrong environment fails with `ENVIRONMENT_MISMATCH` — keys
    never cross environments. Promotion to production is a credential change, never a code change.

    ## Authentication

    `Authorization: Bearer <secret API key>` on every request. Keys are per tenant and per environment.
    Two keys may be active at once so rotation is zero-downtime: create the new key, deploy, revoke the old.
    Optional per-tenant controls (configured at onboarding): source-IP allowlist, and mutual TLS on
    `GET /data-sessions/{id}/data` — the only endpoint over which financial information crosses.

    ## Versioning

    The major version is in the path (`/v1`). Within a major version we make **additive changes only**:
    new endpoints, new optional request fields, new response fields, and **new members of existing enums**.

    Clients MUST tolerate unknown enum members (treat as an "other" bucket) and unknown response fields.
    Reason-code and error-code vocabularies grow as connectors are added; a client that rejects unknown
    members will break on a change that is, by this policy, non-breaking.

    Breaking changes ship as `/v2`. A deprecated major version is supported for at least 12 months from the
    deprecation announcement; deprecated endpoints return a `Sunset` header (RFC 8594) and a
    `Deprecation` header for the whole of that window.

    ## Idempotency

    `Idempotency-Key` is **required** on `POST /consents` and `POST /data-sessions` and **honored** on
    `POST /consents/{id}/revoke`. Use a fresh UUIDv4 per logical operation and reuse it on retries.

    - Keys are scoped to `(tenant, environment, endpoint, key)` and retained for **24 hours**.
    - A replayed key with an identical request body returns the original response with
      `Idempotency-Replayed: true`.
    - A replayed key with a **different** body returns `409 IDEMPOTENCY_KEY_REUSED`.
    - A key whose original request is still in flight returns `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`;
      retry after the `Retry-After` interval.

    `POST /consents/{id}/journey` **ignores** `Idempotency-Key` by design: minting a fresh, short-lived
    journey URL is the operation's purpose, and each call supersedes the previous URL.

    ## Pagination

    Collections use opaque forward cursors: `?limit=<1..100>&cursor=<opaque>`. Responses are
    `{ "object": "list", "data": [...], "has_more": bool, "next_cursor": string|null }`. Cursors encode
    sort position and filters; do not construct or mutate them. Default sort is `created_at` descending.
    `GET /data-sessions/{id}/data` paginates over **accounts** (default `limit` 10, max 50) because
    per-account FI payloads are large.

    ## Errors

    Every non-2xx response is `{ "error": { ... } }` with:
    `type` (broad class, safe to switch on), `code` (stable, machine-readable, SCREAMING_SNAKE),
    `message` (human-readable; never for programmatic use), `param` (offending field, JSON pointer),
    `retryable` (whether an identical retry can succeed), `request_id`, and `details` (code-specific).

    **Upstream errors never leak.** An AA's or wrapper's own error codes, statuses and messages are mapped
    inside the connector to this taxonomy and are never surfaced. Where an upstream condition caused the
    error, `details.upstream_ref` carries an opaque incident id that Lender OS support can resolve — that is
    the only handle you get, and the only one you need.

    ## Data custody

    Every consent and session reports `custody.decryption_performed_by`:
    `LENDEROS` — Lender OS performed the key exchange and decrypted the payload inside your isolated key
    boundary; `RAIL` — the contracted rail delivered already-decrypted data to us. This is recorded in the
    evidence pack for every fetch. It is a property of the rail your consent was routed to, and it is
    reported, not editorialised.

    ## Non-serviceability is not an error

    Some borrowers cannot be served by the AA framework at all (joint-only accounts, NRE/NRO,
    non-proprietor current accounts, no FIP live on any AA you have contracted). This is a **structural
    outcome**, not a failure: it arrives as a `serviceability` object with `status: CANNOT_SERVE` and a
    machine-readable reason, on an HTTP 200 response and on the corresponding webhook. Switch rails on it;
    do not retry it.

  contact:
    name: Lender OS Platform Support
    email: platform@lenderos.in
  license:
    name: Proprietary — © Lender OS. All rights reserved.
    identifier: LicenseRef-LenderOS-Proprietary
  x-canonical-source: orchestrator/docs/openapi.yaml
  x-grounding:
    - PRD.md §3.2, §3.3, §3.4, §3.5
    - TRD.md §1 (resource model), §2 (state machines + reason codes), §6.1 (journey), §7.3 (idempotency, webhooks)
    - DECISIONS.md D-002, D-006, D-007, D-012, D-013, D-014, D-015, D-016, D-020, D-023, D-024, D-025..D-035

servers:
  - url: https://api.lenderos.in/v1
    description: Production
  - url: https://api.sandbox.lenderos.in/v1
    description: Sandbox — mock FI data, no AA contract required

security:
  - apiKey: []

tags:
  - name: Consents
    description: Compile a consent, launch the borrower journey, observe lifecycle, revoke.
  - name: Data sessions
    description: Acquire financial information within an active consent's terms and retrieve the raw payload.
  - name: Webhooks
    description: Outbound event delivery. Events carry references; financial information is never in a webhook body.

x-error-mapping:
  summary: >-
    How upstream (AA / TSP) errors become Lender OS error codes. This is a build-time contract on connectors,
    documented here so integrators understand what they will and will not see.
  contract:
    - Each connector ships a total mapping `error_map`: upstream error identity -> { lenderos_code, retryable, tenant_message_key }.
    - Mapping completeness is enforced in connector tests against the connector's pinned spec version (DECISIONS D-008).
    - An upstream error with no mapping entry becomes `UPSTREAM_UNMAPPED` (HTTP 502), raises an ops alert,
      and is treated as a connector bug — never passed through, never guessed at.
    - Upstream codes, statuses and messages are never included in any tenant-facing response. The only
      cross-reference is `details.upstream_ref`, an opaque incident id.
    - Per-connector tables live in `orchestrator/docs/CONNECTOR_NOTES.md`, which is the maintained source of truth.
  example_public_spec_mapping:
    note: >-
      Illustrative only, from the public ReBIT AA 2.1.0 error enums. Wrapper/TSP mappings are commercially
      confidential and are not reproduced here or in any client-facing material.
    entries:
      - upstream: InvalidConsentStatus
        lenderos_code: CONSENT_NOT_ACTIVE
        retryable: false
      - upstream: InvalidConsentId | InvalidConsentHandle
        lenderos_code: UPSTREAM_REJECTED
        retryable: false
      - upstream: InvalidDateRange
        lenderos_code: DATA_RANGE_OUTSIDE_CONSENT
        retryable: false
      - upstream: InvalidConsentUse
        lenderos_code: FETCH_FREQUENCY_EXCEEDED
        retryable: false
      - upstream: SignatureDoesNotMatch | InvalidSecurity
        lenderos_code: INTERNAL_ERROR
        retryable: false
        note: Our signing failed. Never the tenant's problem; pages us.
      - upstream: ServiceUnavailable
        lenderos_code: UPSTREAM_UNAVAILABLE
        retryable: true
      - upstream: FIStatus=DENIED
        lenderos_code: null
        note: Not an API error — becomes account-level failure_reason FIP_DENIED on the data session.

x-webhook-delivery:
  transport: HTTPS POST to the tenant's configured endpoint, JSON body, TLS 1.2+.
  signature:
    header: LenderOS-Signature
    format: 't=<unix_seconds>,v1=<hex_hmac_sha256>'
    computation: HMAC-SHA256(secret, "<t>" + "." + <exact raw request body bytes>)
    verification:
      - Recompute over the RAW body bytes, before any JSON parsing or re-serialisation.
      - Compare in constant time.
      - Reject if |now - t| > 300 seconds (replay protection).
      - During secret rotation the header carries multiple v1 values (comma-separated); accept if ANY matches.
    secret: Per-endpoint, rotatable from the ops dashboard. Rotation keeps both secrets valid for 24h.
  retries:
    success_criterion: HTTP 2xx within 10 seconds.
    schedule_seconds: [0, 60, 300, 900, 1800, 3600, 7200, 14400, 28800, 43200]
    jitter: '±20% applied per attempt'
    max_attempts: 10
    max_elapsed: 24h
    on_exhaustion: 'Delivery is marked DEAD, surfaced in the ops dashboard, and can be manually redelivered.'
    non_retryable_responses: 'HTTP 410 Gone from your endpoint stops retries for that delivery immediately.'
  ordering: >-
    Delivery is at-least-once and ordering is best-effort. Deduplicate on the event `id`. Where order matters,
    use `sequence` — it is monotonically increasing per entity (consent or data session) and gapless per entity.
    Discard an event whose `sequence` is lower than one you have already applied to that entity.
  payload_policy: >-
    Webhooks carry events and references only. Financial information is never in a webhook body — it crosses
    only the authenticated pull API. This keeps borrower data off retry queues and out of your webhook logs.

paths:
  /consents:
    post:
      tags: [Consents]
      summary: Create a consent
      operationId: createConsent
      description: |
        Compiles the supplied consent parameters, validates them against Fair Use bounds, selects a rail from
        your routable pool (the intersection of the connectors we support and the AAs you have contracted),
        and registers the consent with that rail.

        **Routing is decided here and is final for this consent** — there is no mid-journey failover, because
        switching AA forces the borrower to re-link accounts. If no rail in your pool can express these consent
        terms, this call fails with `422 NO_ROUTE_FOR_CONSENT_TERMS` and tells you which rails were excluded
        and why, so you can decide whether to relax the terms or route the file elsewhere.

        The consent is returned in state `PENDING`. Nothing is consented until the borrower completes the
        journey and the rail confirms it: watch for `consent.activated`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConsentCreateRequest'
            examples:
              inline_params:
                summary: Full inline consent parameters
                value:
                  client_reference: LOAN-APP-88213
                  customer:
                    reference: cust_9f2c1a
                    mobile: '+919812345678'
                    pan: ABCDE1234F
                  consent_params:
                    purpose:
                      code: '101'
                      text: Wealth management service
                    fi_types: [DEPOSIT]
                    consent_mode: STORE
                    fetch_type: PERIODIC
                    consent_start: '2026-07-12T00:00:00Z'
                    consent_expiry: '2027-07-12T00:00:00Z'
                    fi_data_range:
                      from: '2025-07-12T00:00:00Z'
                      to: '2026-07-12T00:00:00Z'
                    frequency:
                      unit: MONTH
                      value: 2
                    data_life:
                      unit: MONTH
                      value: 6
                  journey:
                    return_url: https://lender.example.com/aa/return
                    mint_immediately: true
                  metadata:
                    branch: BLR-04
              template_preset:
                summary: Named Lender OS preset, expanded server-side to the same canonical parameters
                value:
                  client_reference: LOAN-APP-88214
                  customer:
                    reference: cust_2b7d40
                    mobile: '+919812345678'
                  template_ref: tpl_personal_loan_12m
                  journey:
                    return_url: https://lender.example.com/aa/return
      responses:
        '201':
          description: Consent created and registered with the selected rail.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
            Idempotency-Replayed: { $ref: '#/components/headers/IdempotencyReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableConsent' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    get:
      tags: [Consents]
      summary: List consents
      operationId: listConsents
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: state
          in: query
          description: Filter by consent state. Repeatable.
          schema:
            type: array
            items: { $ref: '#/components/schemas/ConsentState' }
          style: form
          explode: true
        - name: client_reference
          in: query
          schema: { type: string, maxLength: 128 }
        - name: customer_reference
          in: query
          schema: { type: string, maxLength: 128 }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: A page of consents, newest first.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsentList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /consents/{id}:
    parameters:
      - $ref: '#/components/parameters/ConsentId'
    get:
      tags: [Consents]
      summary: Retrieve a consent
      operationId: getConsent
      description: |
        Returns the current authoritative state of the consent. State transitions come only from verified rail
        notifications, authoritative status polls, or the expiry clock (poll-confirmed). The borrower's return
        from the AA is advisory: it populates `journey.outcome` for your UX and funnel metrics, and never moves
        the consent by itself. A `journey.outcome` of `SUCCESS` with `state: PENDING` is normal for a few
        seconds and is not an error.
      responses:
        '200':
          description: The consent.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /consents/{id}/journey:
    parameters:
      - $ref: '#/components/parameters/ConsentId'
    post:
      tags: [Consents]
      summary: Mint a consent-journey URL
      operationId: mintConsentJourney
      description: |
        Mints a **short-lived** URL to which you redirect the borrower (browser or webview). Call this
        immediately before redirecting — the URL's validity window is measured in minutes and, on some rails,
        in seconds. Calling it again mints a fresh URL and **supersedes the previous one**; the consent handle
        is unchanged.

        `Idempotency-Key` is ignored on this endpoint by design.

        The consent journey is a redirect, not an iframe: the AA leg always leaves your domain. The borrower
        returns to the `return_url` on the consent with `consent_id` and `journey_txn_id` appended as query
        parameters. Treat that return as a UX signal only — the authoritative outcome is the
        `consent.activated` / `consent.rejected` webhook.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                return_url:
                  type: string
                  format: uri
                  description: >-
                    Overrides the consent's return URL for this journey only. Must be HTTPS and must match an
                    allowlisted origin registered for your tenant.
      responses:
        '201':
          description: A freshly minted journey URL.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JourneyLaunch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The consent is not in a state that can be launched (it is already terminal, or already active).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  type: conflict_error
                  code: CONSENT_STATE_INVALID
                  message: A journey can only be launched for a consent in state PENDING.
                  retryable: false
                  request_id: req_01J8Z9
                  details:
                    current_state: ACTIVE
                    allowed_states: [PENDING]
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /consents/{id}/revoke:
    parameters:
      - $ref: '#/components/parameters/ConsentId'
    post:
      tags: [Consents]
      summary: Revoke a consent
      operationId: revokeConsent
      description: |
        Revokes an `ACTIVE` or `PAUSED` consent at the rail. Revocation stops all further data acquisition
        immediately and locally, before the rail confirms.

        Revocation does **not** by itself destroy data already delivered to you: the purge clock is the
        consent's data-life, and purge is executed and attested independently. (This reading is under
        compliance review; if it changes, revocation will additionally accelerate purge and you will see the
        `purge_due_at` on affected sessions move earlier.)

        The response is `202` while the rail confirms; the authoritative transition arrives as the
        `consent.revoked` webhook. If the rail confirms synchronously, the returned consent is already
        `REVOKED`. Revoking an already-`REVOKED` consent is a no-op and returns `200`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  maxLength: 256
                  description: Your own note, recorded in the evidence log. Not sent to the rail.
                metadata: { $ref: '#/components/schemas/Metadata' }
      responses:
        '200':
          description: Consent was already revoked; no action taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
        '202':
          description: Revocation accepted and dispatched to the rail.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The consent cannot be revoked from its current state (e.g. it is still `PENDING`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  type: conflict_error
                  code: CONSENT_STATE_INVALID
                  message: Only ACTIVE or PAUSED consents can be revoked.
                  retryable: false
                  request_id: req_01J8ZA
                  details:
                    current_state: PENDING
                    allowed_states: [ACTIVE, PAUSED]
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }

  /data-sessions:
    post:
      tags: [Data sessions]
      summary: Request financial information
      operationId: createDataSession
      description: |
        Opens a data acquisition attempt under an `ACTIVE` consent. The requested window and the fetch
        frequency are enforced **locally, before the rail sees the request** — an out-of-bounds request fails
        here with a typed error rather than being rejected downstream.

        Scheduling is yours: the orchestrator does not run a re-fetch scheduler. You decide when to fetch;
        we enforce that you may.

        **Vendor-initiated sessions.** On some rails the first data drop after consent activation arrives
        automatically. In that case a session already exists (`origin: VENDOR_INITIATED`). If you call this
        endpoint while such a session is open and its window covers your requested range, we return that
        session with `200` instead of creating a second one — this prevents you paying twice for the same
        account fetch. A newly created session returns `201`.

        Data never arrives in the response to this call. Wait for `session.data_ready`, then pull.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DataSessionCreateRequest' }
            examples:
              default:
                value:
                  consent_id: con_01J8Y7QN4M5R
                  fi_data_range:
                    from: '2025-07-12T00:00:00Z'
                    to: '2026-07-12T00:00:00Z'
                  client_reference: LOAN-APP-88213-FETCH-1
      responses:
        '200':
          description: An existing open session covering this request was reused. No new fetch was initiated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataSession' }
        '201':
          description: Data session created.
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
            Idempotency-Replayed: { $ref: '#/components/headers/IdempotencyReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableSession' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/UpstreamError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    get:
      tags: [Data sessions]
      summary: List data sessions
      operationId: listDataSessions
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: consent_id
          in: query
          schema: { type: string }
        - name: state
          in: query
          schema:
            type: array
            items: { $ref: '#/components/schemas/DataSessionState' }
          style: form
          explode: true
        - name: origin
          in: query
          schema: { $ref: '#/components/schemas/SessionOrigin' }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: A page of data sessions, newest first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataSessionList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /data-sessions/{id}:
    parameters:
      - $ref: '#/components/parameters/DataSessionId'
    get:
      tags: [Data sessions]
      summary: Retrieve a data session
      operationId: getDataSession
      description: |
        Returns the session's state, per-account summary, custody record and lifecycle timestamps.

        The state machine is identical on every rail. On rails where the data is pushed to us already
        decrypted, the intermediate states are recorded with coincident timestamps and `fast_forwarded: true`
        — the timeline is honest about what actually happened, and your integration does not branch.
      responses:
        '200':
          description: The data session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DataSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /data-sessions/{id}/data:
    parameters:
      - $ref: '#/components/parameters/DataSessionId'
    get:
      tags: [Data sessions]
      summary: Retrieve financial information for a data session
      operationId: getDataSessionData
      description: |
        The **only** endpoint over which financial information crosses. Returns a normalized envelope wrapping
        the **raw, untransformed ReBIT FI JSON per account**. We normalize the wrapper, never the data: your
        parser reads ReBIT schemas, not Lender OS inventions.

        Paginates over accounts. A first successful retrieval of every available account records the custody
        handoff and fires `session.data_delivered`.

        **Partial results are results.** If some accounts failed at their FIP and others succeeded, `status`
        is `PARTIAL`: the successful accounts carry payloads, the failed ones carry a `failure_reason`. This
        is a delivery, not a failure.

        **After purge**, this endpoint returns `200` with `status: PURGED` and a purge attestation reference —
        not a 404. The data is gone; the evidence that it was destroyed is not.
      parameters:
        - name: limit
          in: query
          description: Accounts per page.
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - $ref: '#/components/parameters/Cursor'
        - name: include_payload
          in: query
          description: Set `false` to retrieve the account manifest (statuses, hashes, metadata) without payloads.
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: |
            The FI envelope. Inspect `status`: `AVAILABLE` (complete), `PARTIAL` (some accounts failed),
            `PENDING` (session has not reached delivery yet — no payloads present), `PURGED` (data-life
            elapsed; attestation reference returned).
          headers:
            LenderOS-Request-Id: { $ref: '#/components/headers/LenderOSRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FiData' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            Retrieval blocked by a tenant-level control (e.g. mTLS required on this endpoint, or source IP
            not allowlisted).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

webhooks:
  consent.activated:
    post:
      tags: [Webhooks]
      summary: The borrower approved the consent and the rail confirmed it
      operationId: onConsentActivated
      description: |
        Authoritative. The consent artefact exists and is active; data sessions may now be opened. On rails
        that push the first data drop automatically, a `VENDOR_INITIATED` data session is created at this
        moment and you will receive `session.data_ready` for it without having called `POST /data-sessions`.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentEvent' }
            example:
              id: evt_01J8ZB4KQ1
              object: event
              type: consent.activated
              api_version: v1
              livemode: true
              created_at: '2026-07-12T09:14:02.117Z'
              occurred_at: '2026-07-12T09:14:01.902Z'
              sequence: 3
              data:
                previous_state: PENDING
                object:
                  id: con_01J8Y7QN4M5R
                  object: consent
                  state: ACTIVE
                  aa: { id: aa-one, display_name: AA One }
                  custody: { decryption_performed_by: LENDEROS }
                  client_reference: LOAN-APP-88213
      responses:
        '2XX':
          description: Acknowledged. Any 2xx within 10s stops retries.
  consent.rejected:
    post:
      tags: [Webhooks]
      summary: The borrower declined, or the rail rejected the consent
      operationId: onConsentRejected
      description: |
        Terminal. `data.object.state_reason` distinguishes an explicit borrower rejection from a journey or
        rail failure. If `data.object.serviceability.status` is `CANNOT_SERVE`, this borrower cannot be served
        through the AA framework on your contracted rails — switch rails rather than retrying.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentEvent' }
      responses:
        '2XX': { description: Acknowledged. }
  consent.revoked:
    post:
      tags: [Webhooks]
      summary: The consent was revoked — by the borrower at the AA, or by you
      operationId: onConsentRevoked
      description: |
        Terminal. Fires for borrower-initiated revocation at the AA app as well as revocation you requested.
        Flag the credit file on receipt: no further data may be acquired under this consent. Already-delivered
        data remains subject to its own data-life purge clock.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentEvent' }
      responses:
        '2XX': { description: Acknowledged. }
  consent.paused:
    post:
      tags: [Webhooks]
      summary: The consent was paused at the AA
      operationId: onConsentPaused
      description: |
        Not terminal. Data acquisition is suspended until the borrower resumes it; a resume arrives as
        `consent.activated` with `data.previous_state: PAUSED`. Requests for data on a paused consent fail with
        `CONSENT_NOT_ACTIVE`.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentEvent' }
      responses:
        '2XX': { description: Acknowledged. }
  consent.expired:
    post:
      tags: [Webhooks]
      summary: The consent reached its expiry
      operationId: onConsentExpired
      description: |
        Terminal. Fires when the consent's own validity window ends. Expiry is confirmed against the rail
        before this event is emitted, so it is not merely a local clock artefact.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConsentEvent' }
      responses:
        '2XX': { description: Acknowledged. }
  session.data_ready:
    post:
      tags: [Webhooks]
      summary: Financial information is available to pull
      operationId: onSessionDataReady
      description: |
        The session reached `DELIVERED`: the payload is retrievable at `data.data_url`. Check
        `data.completeness` — `PARTIAL` means some accounts failed at their FIP and the rest are available now.

        This event contains no financial information. Pull it from `data_url`.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionDataReadyEvent' }
            example:
              id: evt_01J8ZC7T2H
              object: event
              type: session.data_ready
              api_version: v1
              livemode: true
              created_at: '2026-07-12T09:16:44.031Z'
              occurred_at: '2026-07-12T09:16:43.780Z'
              sequence: 5
              data:
                previous_state: DECRYPTED
                data_url: https://api.lenderos.in/v1/data-sessions/dsn_01J8ZC5R9F/data
                completeness: PARTIAL
                object:
                  id: dsn_01J8ZC5R9F
                  object: data_session
                  consent_id: con_01J8Y7QN4M5R
                  state: DELIVERED
                  origin: VENDOR_INITIATED
                  fast_forwarded: true
                  aa: { id: aa-two, display_name: AA Two }
                  custody: { decryption_performed_by: RAIL }
                  accounts_summary: { total: 3, delivered: 2, failed: 1, pending: 0 }
                  purge_due_at: '2027-01-12T09:16:43.780Z'
      responses:
        '2XX': { description: Acknowledged. }
  session.data_delivered:
    post:
      tags: [Webhooks]
      summary: You retrieved the financial information — custody handoff recorded
      operationId: onSessionDataDelivered
      description: |
        Fires the first time every available account in the session has been successfully retrieved through
        the pull API. This is the custody-handoff record in the evidence pack, and your confirmation that the
        payload landed. `data.object.purge_due_at` is the date on which we will destroy our copy and attest
        to it.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionDeliveredEvent' }
      responses:
        '2XX': { description: Acknowledged. }
  session.failed:
    post:
      tags: [Webhooks]
      summary: The data session failed
      operationId: onSessionFailed
      description: |
        Terminal, and only fires when **every** account in the session is terminally failed — a session with
        any successful account is a `session.data_ready`, not a failure.

        `data.object.state_reason` tells you what to do: `FIP_TIMEOUT` and `UPSTREAM_UNAVAILABLE` are
        retryable with a new session; `CONSENT_NOT_ACTIVE` means the consent moved under you; a
        `serviceability.status` of `CANNOT_SERVE` means retrying anything will not help.
      parameters: [ { $ref: '#/components/parameters/WebhookSignature' }, { $ref: '#/components/parameters/WebhookEventId' }, { $ref: '#/components/parameters/WebhookDeliveryId' }, { $ref: '#/components/parameters/WebhookDeliveryAttempt' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionFailedEvent' }
      responses:
        '2XX': { description: Acknowledged. }

components:

  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: Lender OS secret API key (los_sk_live_… / los_sk_test_…)
      description: |
        Per-tenant, per-environment secret key. Server-side only — never ship a key to a browser, mobile app
        or borrower device. Two keys may be active during rotation.

  parameters:
    ConsentId:
      name: id
      in: path
      required: true
      description: Consent id (`con_…`).
      schema: { type: string, pattern: '^con_[A-Za-z0-9]{10,32}$' }
    DataSessionId:
      name: id
      in: path
      required: true
      description: Data session id (`dsn_…`).
      schema: { type: string, pattern: '^dsn_[A-Za-z0-9]{10,32}$' }
    Limit:
      name: limit
      in: query
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      description: Opaque forward cursor from `next_cursor`. Do not construct or mutate.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique key for this logical operation (UUIDv4 recommended). Reuse on retries; replays are safe for 24h.
      schema: { type: string, minLength: 8, maxLength: 128 }
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: Optional here; the operation is idempotent by nature.
      schema: { type: string, minLength: 8, maxLength: 128 }
    WebhookSignature:
      name: LenderOS-Signature
      in: header
      required: true
      description: '`t=<unix>,v1=<hex HMAC-SHA256 over "t.rawbody">`. Multiple `v1=` values may appear during secret rotation.'
      schema: { type: string }
    WebhookEventId:
      name: LenderOS-Event-Id
      in: header
      required: true
      description: Same as the body's `id`. Deduplicate on this.
      schema: { type: string }
    WebhookDeliveryId:
      name: LenderOS-Delivery-Id
      in: header
      required: true
      description: Identifies this delivery attempt chain; changes on manual redelivery.
      schema: { type: string }
    WebhookDeliveryAttempt:
      name: LenderOS-Delivery-Attempt
      in: header
      required: true
      description: 1-based attempt counter (max 10).
      schema: { type: integer, minimum: 1, maximum: 10 }

  headers:
    LenderOSRequestId:
      description: Echo this to support; it resolves the entire request trace.
      schema: { type: string }
    IdempotencyReplayed:
      description: '`true` when this response was replayed from a previously completed identical request.'
      schema: { type: boolean }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix seconds at which the current rate-limit window resets.
      schema: { type: integer }

  responses:
    BadRequest:
      description: Malformed request or invalid parameter.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: invalid_request_error
              code: INVALID_PARAMETER
              message: consent_params.fi_data_range.to must be later than .from
              param: /consent_params/fi_data_range/to
              retryable: false
              request_id: req_01J8ZD
    Unauthorized:
      description: Missing, malformed, revoked, or wrong-environment API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: authentication_error
              code: ENVIRONMENT_MISMATCH
              message: A sandbox key was presented to the production API.
              retryable: false
              request_id: req_01J8ZE
    Forbidden:
      description: Authenticated, but not permitted (tenant suspended, IP not allowlisted, endpoint not enabled).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: No such resource for this tenant.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: Idempotency conflict or invalid state transition.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            key_reused:
              value:
                error:
                  type: conflict_error
                  code: IDEMPOTENCY_KEY_REUSED
                  message: This Idempotency-Key was used for a request with a different body.
                  retryable: false
                  request_id: req_01J8ZF
            in_progress:
              value:
                error:
                  type: conflict_error
                  code: IDEMPOTENCY_REQUEST_IN_PROGRESS
                  message: A request with this Idempotency-Key is still being processed.
                  retryable: true
                  request_id: req_01J8ZG
    UnprocessableConsent:
      description: |
        The request is well-formed but cannot be fulfilled: the consent terms breach Fair Use bounds, or no
        contracted rail can express them.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            no_route_for_terms:
              summary: No contracted rail can express these consent terms
              value:
                error:
                  type: routing_error
                  code: NO_ROUTE_FOR_CONSENT_TERMS
                  message: >-
                    None of your contracted rails can be provisioned for these consent parameters. Rails that
                    only support pre-provisioned parameter sets must have this exact combination provisioned
                    in advance; contact Lender OS onboarding to provision it, or adjust the parameters to match
                    an existing set.
                  retryable: false
                  request_id: req_01J8ZH
                  details:
                    considered:
                      - aa: { id: aa-two, display_name: AA Two }
                        excluded_because: CONSENT_TERMS_NOT_SUPPORTED
                      - aa: { id: aa-one, display_name: AA One }
                        excluded_because: AA_NOT_CONTRACTED
                    supported_parameter_sets_hint: >-
                      Your provisioned parameter sets are listed in the dashboard under Consent templates.
            fair_use:
              summary: Consent terms exceed Fair Use bounds
              value:
                error:
                  type: invalid_request_error
                  code: CONSENT_PARAMS_OUT_OF_FAIR_USE_BOUNDS
                  message: The requested data range exceeds the Fair Use maximum for this purpose code.
                  param: /consent_params/fi_data_range/from
                  retryable: false
                  request_id: req_01J8ZI
                  details:
                    bound: fi_data_range_months_max
                    requested: 36
                    maximum: 12
                    purpose_code: '101'
    UnprocessableSession:
      description: The consent's own terms forbid this fetch.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            frequency:
              value:
                error:
                  type: consent_enforcement_error
                  code: FETCH_FREQUENCY_EXCEEDED
                  message: This consent permits 2 fetches per MONTH; 2 have been used in the current window.
                  retryable: true
                  request_id: req_01J8ZJ
                  details:
                    frequency: { unit: MONTH, value: 2 }
                    used_in_window: 2
                    window_resets_at: '2026-08-01T00:00:00Z'
            range:
              value:
                error:
                  type: consent_enforcement_error
                  code: DATA_RANGE_OUTSIDE_CONSENT
                  message: The requested window is not fully contained in the consent's data range.
                  param: /fi_data_range/from
                  retryable: false
                  request_id: req_01J8ZK
                  details:
                    consent_range: { from: '2025-07-12T00:00:00Z', to: '2026-07-12T00:00:00Z' }
                    requested_range: { from: '2024-01-01T00:00:00Z', to: '2026-07-12T00:00:00Z' }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    UpstreamError:
      description: |
        A contracted rail rejected, timed out on, or was unavailable for this operation. `retryable` says
        whether an identical retry can succeed. Upstream codes are never surfaced; quote `details.upstream_ref`
        to support.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              type: upstream_error
              code: UPSTREAM_UNAVAILABLE
              message: The selected Account Aggregator did not respond in time.
              retryable: true
              request_id: req_01J8ZL
              details:
                aa: { id: aa-one, display_name: AA One }
                upstream_ref: ups_01J8ZL7Q
    InternalError:
      description: Our fault. Safe to retry with the same Idempotency-Key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ServiceUnavailable:
      description: Temporarily unable to serve. Retry with backoff.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:

    # ---------- Errors ----------

    ErrorEnvelope:
      type: object
      required: [error]
      additionalProperties: false
      properties:
        error: { $ref: '#/components/schemas/Error' }

    Error:
      type: object
      required: [type, code, message, retryable, request_id]
      properties:
        type:
          $ref: '#/components/schemas/ErrorType'
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: string
          description: Human-readable. Never parse this; switch on `code`.
        param:
          type: [string, 'null']
          description: JSON Pointer to the offending request field, when applicable.
        retryable:
          type: boolean
          description: Whether an identical retry (same Idempotency-Key) can plausibly succeed.
        request_id:
          type: string
        doc_url:
          type: [string, 'null']
          format: uri
        details:
          type: [object, 'null']
          description: |
            Code-specific structured context. Notable members: `upstream_ref` (opaque incident id for
            upstream-caused errors), `considered` (routing exclusion list), `current_state`/`allowed_states`
            (state conflicts), `window_resets_at` (frequency enforcement).
          additionalProperties: true

    ErrorType:
      type: string
      description: Broad class. Stable; safe to switch on.
      enum:
        - invalid_request_error
        - authentication_error
        - permission_error
        - not_found_error
        - conflict_error
        - rate_limit_error
        - routing_error
        - consent_enforcement_error
        - upstream_error
        - api_error

    ErrorCode:
      type: string
      description: |
        Stable machine code. New members may be added within a major version — treat unknown codes as
        `INTERNAL_ERROR`-equivalent and honour the `retryable` flag.
      enum:
        # authentication_error
        - INVALID_API_KEY
        - REVOKED_API_KEY
        - ENVIRONMENT_MISMATCH
        # permission_error
        - TENANT_SUSPENDED
        - IP_NOT_ALLOWED
        - MTLS_REQUIRED
        - ENDPOINT_NOT_ENABLED
        # invalid_request_error
        - INVALID_REQUEST
        - MISSING_PARAMETER
        - INVALID_PARAMETER
        - INVALID_RETURN_URL
        - UNSUPPORTED_FI_TYPE
        - INVALID_CONSENT_PARAMS
        - CONSENT_PARAMS_OUT_OF_FAIR_USE_BOUNDS
        # not_found_error
        - CONSENT_NOT_FOUND
        - SESSION_NOT_FOUND
        - TEMPLATE_NOT_FOUND
        # conflict_error
        - IDEMPOTENCY_KEY_REUSED
        - IDEMPOTENCY_REQUEST_IN_PROGRESS
        - CONSENT_STATE_INVALID
        - SESSION_STATE_INVALID
        # routing_error
        - NO_ROUTE_NO_CONTRACTED_AA
        - NO_ROUTE_FOR_CONSENT_TERMS
        - NO_ROUTE_ALL_RAILS_UNAVAILABLE
        # consent_enforcement_error
        - CONSENT_NOT_ACTIVE
        - CONSENT_EXPIRED
        - DATA_RANGE_OUTSIDE_CONSENT
        - FETCH_FREQUENCY_EXCEEDED
        - FI_TYPE_NOT_IN_CONSENT
        # rate_limit_error
        - RATE_LIMITED
        # upstream_error
        - UPSTREAM_REJECTED
        - UPSTREAM_TIMEOUT
        - UPSTREAM_UNAVAILABLE
        - UPSTREAM_UNMAPPED
        # api_error
        - INTERNAL_ERROR
        - SERVICE_UNAVAILABLE

    RailExclusionReason:
      type: string
      description: |
        Why a rail was excluded from routing for this consent. Deliberately expressed in terms of capability
        and contract, not vendor mechanics.
      enum:
        - AA_NOT_CONTRACTED
        - CONSENT_TERMS_NOT_SUPPORTED
        - FI_TYPE_NOT_SUPPORTED
        - RAIL_SUSPENDED
        - RAIL_UNAVAILABLE

    # ---------- Shared primitives ----------

    Metadata:
      type: object
      description: Free-form key/value pairs echoed on the object and on every webhook that carries it. Max 20 keys, 512 chars per value.
      maxProperties: 20
      additionalProperties: { type: string, maxLength: 512 }

    Aa:
      type: object
      description: The Account Aggregator that served this consent or session. Stable id, suitable for metrics and invoice reconciliation.
      required: [id, display_name]
      properties:
        id: { type: string, examples: [aa-one, aa-two] }
        display_name: { type: string, examples: [AA One, AA Two] }

    Custody:
      type: object
      description: Who decrypted the financial information for this consent's sessions.
      required: [decryption_performed_by]
      properties:
        decryption_performed_by:
          type: string
          enum: [LENDEROS, RAIL]
          description: |
            `LENDEROS` — the key exchange and decryption happened inside your isolated key boundary at Lender OS.
            `RAIL` — the contracted rail decrypted the data and delivered it to us already in the clear.
            Recorded per fetch in the evidence pack.

    DateRange:
      type: object
      required: [from, to]
      additionalProperties: false
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }

    Purpose:
      type: object
      required: [code]
      additionalProperties: false
      properties:
        code:
          type: string
          description: ReBIT purpose code (e.g. `101`). Validated against the Fair Use bounds for that purpose.
          examples: ['101', '102', '103']
        text:
          type: string
          maxLength: 256
          description: Human-readable purpose shown to the borrower. Defaults to the standard text for the code.

    FrequencyRule:
      type: object
      description: Maximum fetches per unit. Enforced locally on every data-session request, before the rail sees it.
      required: [unit, value]
      additionalProperties: false
      properties:
        unit: { type: string, enum: [HOUR, DAY, MONTH, YEAR] }
        value: { type: integer, minimum: 1 }

    DataLife:
      type: object
      description: |
        How long Lender OS may retain the fetched data. Drives `purge_due_at` on every session; purge is executed
        and attested, not merely scheduled.
      required: [unit, value]
      additionalProperties: false
      properties:
        unit: { type: string, enum: [DAY, MONTH, YEAR, INF] }
        value: { type: integer, minimum: 1, description: 'Ignored when unit is INF.' }

    FiType:
      type: string
      description: 'v1 supports `DEPOSIT` only. Additional types are added as coverage justifies; unknown members must not break your client.'
      enum: [DEPOSIT]

    ConsentParams:
      type: object
      description: |
        The canonical consent parameters. This API accepts them in full — nothing here is a preset you cannot
        override. They are validated against Fair Use bounds before submission to any rail, and enforced in our
        code on every subsequent data request.
      required: [purpose, fi_types, consent_mode, fetch_type, consent_start, consent_expiry, fi_data_range, data_life]
      additionalProperties: false
      properties:
        purpose: { $ref: '#/components/schemas/Purpose' }
        fi_types:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/FiType' }
        consent_mode:
          type: string
          enum: [STORE, VIEW, QUERY, STREAM]
          description: 'For raw payload delivery to your systems, `STORE` is the applicable mode.'
        fetch_type:
          type: string
          enum: [ONETIME, PERIODIC]
        consent_start: { type: string, format: date-time }
        consent_expiry:
          type: string
          format: date-time
          description: Enforced in code. Data sessions are refused after this instant even if the rail is slow to expire the consent.
        fi_data_range: { $ref: '#/components/schemas/DateRange' }
        frequency:
          $ref: '#/components/schemas/FrequencyRule'
          description: Required when `fetch_type` is `PERIODIC`; must be omitted when `ONETIME`.
        data_life: { $ref: '#/components/schemas/DataLife' }

    Serviceability:
      type: object
      description: |
        Whether the AA framework can serve this borrower at all, on the rails you have contracted. Populated at
        account discovery. `CANNOT_SERVE` is an outcome, not an error — switch rails, do not retry.
      required: [status]
      properties:
        status:
          type: string
          enum: [UNKNOWN, SERVICEABLE, CANNOT_SERVE]
        reason:
          type: [string, 'null']
          enum:
            - JOINT_ACCOUNT_ONLY
            - NRE_NRO
            - NON_PROPRIETOR_CURRENT
            - FIP_NOT_ON_CONTRACTED_AA
            - NO_ACCOUNTS_DISCOVERED
            - null
        observed_at: { type: [string, 'null'], format: date-time }

    # ---------- Consent ----------

    ConsentState:
      type: string
      description: |
        Authoritative consent state. Moves only on verified rail notifications, authoritative status polls, or
        the poll-confirmed expiry clock. The borrower's redirect back to you does not move it.
      enum: [PENDING, ACTIVE, PAUSED, REJECTED, REVOKED, EXPIRED, FAILED]

    ConsentStateReason:
      type: string
      description: Why the consent is in its current state. New members may be added.
      enum:
        - USER_REJECTED
        - JOURNEY_ABANDONED
        - CONSENT_REGISTRATION_FAILED
        - CONSENT_HANDLE_FAILED
        - REVOKED_BY_USER
        - REVOKED_BY_FIU
        - PAUSED_BY_USER
        - EXPIRED_BY_TERMS
        - CANNOT_SERVE
        - TERMS_MISMATCH
        - UPSTREAM_UNMAPPED

    JourneyStatus:
      type: string
      enum: [NOT_ISSUED, ISSUED, RETURNED, EXPIRED]

    JourneyOutcome:
      type: string
      description: |
        **Advisory only.** What the borrower's device reported on return from the AA. Use it to drive UX
        immediately ("rejected at the AA — offer the fallback"), never to update your credit file: the
        authoritative signal is the consent webhook.
      enum: [SUCCESS, REJECTED, ABANDONED, ERROR]

    JourneyOutcomeReason:
      type: string
      description: Normalized from the AA-ecosystem redirect error taxonomy. Advisory, like `JourneyOutcome`.
      enum:
        - SUCCESS
        - REJECTED
        - INVALID_REQUEST
        - AUTH_FAILED
        - TIMEOUT_ABANDONED
        - ACCOUNT_NOT_DISCOVERED
        - LINKING_FAILED
        - UNKNOWN

    Journey:
      type: object
      required: [status, return_url]
      properties:
        status: { $ref: '#/components/schemas/JourneyStatus' }
        return_url: { type: string, format: uri }
        url:
          type: [string, 'null']
          format: uri
          description: Present only immediately after minting. Short-lived — redirect the borrower at once.
        issued_at: { type: [string, 'null'], format: date-time }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: After this instant the URL is refused by the AA. Re-mint rather than reuse.
        journey_txn_id:
          type: [string, 'null']
          description: Correlation id for this journey attempt; echoed back to your `return_url`.
        returned_at: { type: [string, 'null'], format: date-time }
        outcome: { oneOf: [ { $ref: '#/components/schemas/JourneyOutcome' }, { type: 'null' } ] }
        outcome_reason: { oneOf: [ { $ref: '#/components/schemas/JourneyOutcomeReason' }, { type: 'null' } ] }

    JourneyLaunch:
      type: object
      description: A freshly minted journey URL. Redirect immediately; do not cache, log, or email it.
      required: [consent_id, url, journey_txn_id, issued_at, expires_at]
      properties:
        object: { type: string, const: journey_launch }
        consent_id: { type: string }
        url: { type: string, format: uri }
        journey_txn_id: { type: string }
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        return_url: { type: string, format: uri }

    CustomerInput:
      type: object
      description: |
        Borrower identifiers needed to reach them at the AA. Only `reference` is stored in the clear against the
        consent; the rest are used to construct the journey and are held under your tenant's encryption keys.
        Supply the minimum the journey needs — PAN is optional and is used only as a FIP pre-discovery hint.
      required: [reference, mobile]
      additionalProperties: false
      properties:
        reference:
          type: string
          maxLength: 128
          description: Your opaque customer reference. Never a raw identifier.
        mobile:
          type: string
          description: E.164, the number the borrower's AA account is registered against.
          pattern: '^\+[1-9]\d{7,14}$'
        aa_handle:
          type: string
          description: The borrower's existing AA handle, if you already know it. Omit for first-time users.
        pan:
          type: string
          description: Optional. Used only as a FIP discovery hint where the rail supports it.
        email:
          type: string
          format: email

    CustomerRef:
      type: object
      required: [reference]
      properties:
        reference: { type: string }
        aa_handle: { type: [string, 'null'] }

    ConsentCreateRequest:
      type: object
      description: |
        Supply **either** `consent_params` (full canonical parameters) **or** `template_ref` (a Lender OS-side saved
        preset that expands to exactly those parameters). Supplying both is an error.
      required: [customer, journey]
      additionalProperties: false
      properties:
        customer: { $ref: '#/components/schemas/CustomerInput' }
        consent_params: { $ref: '#/components/schemas/ConsentParams' }
        template_ref:
          type: string
          description: |
            A named consent-parameter preset saved for your tenant (`tpl_…`). It expands, server-side, to the
            identical `ConsentParams` document you could have sent inline — there is no expressiveness you lose
            by using one, and none you gain.
          pattern: '^tpl_[A-Za-z0-9_\-]{1,64}$'
        routing:
          type: object
          additionalProperties: false
          description: Optional constraints on rail selection. Routing is decided once, at creation, and never changes mid-journey.
          properties:
            preferred_aa:
              type: array
              description: AA ids in priority order. Rails outside your contracted pool are ignored, not errors.
              items: { type: string }
            exclude_aa:
              type: array
              items: { type: string }
        journey:
          type: object
          required: [return_url]
          additionalProperties: false
          properties:
            return_url:
              type: string
              format: uri
              description: HTTPS URL the borrower returns to. Must match an allowlisted origin for your tenant.
            mint_immediately:
              type: boolean
              default: false
              description: |
                When `true`, the response includes a journey URL. Only set this if you are redirecting the
                borrower **now** — journey URLs are short-lived. Otherwise call `POST /consents/{id}/journey`
                at the moment of redirect.
        client_reference:
          type: string
          maxLength: 128
          description: Your business reference (loan application id, etc.). Indexed and searchable.
        metadata: { $ref: '#/components/schemas/Metadata' }

    Consent:
      type: object
      required: [id, object, state, aa, custody, consent_params, customer, journey, livemode, sequence, created_at, updated_at]
      properties:
        id: { type: string, examples: [con_01J8Y7QN4M5R] }
        object: { type: string, const: consent }
        state: { $ref: '#/components/schemas/ConsentState' }
        state_reason: { oneOf: [ { $ref: '#/components/schemas/ConsentStateReason' }, { type: 'null' } ] }
        aa: { $ref: '#/components/schemas/Aa' }
        custody: { $ref: '#/components/schemas/Custody' }
        consent_params:
          $ref: '#/components/schemas/ConsentParams'
          description: The compiled canonical parameters actually submitted to the rail — echoed here whether you sent them inline or via `template_ref`.
        template_ref: { type: [string, 'null'] }
        customer: { $ref: '#/components/schemas/CustomerRef' }
        journey: { $ref: '#/components/schemas/Journey' }
        serviceability: { $ref: '#/components/schemas/Serviceability' }
        revocation:
          type: [object, 'null']
          properties:
            status: { type: string, enum: [REQUESTED, CONFIRMED] }
            requested_at: { type: string, format: date-time }
            confirmed_at: { type: [string, 'null'], format: date-time }
            initiated_by: { type: string, enum: [FIU, USER] }
        client_reference: { type: [string, 'null'] }
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        sequence:
          type: integer
          description: Monotonic per consent. Discard any webhook whose `sequence` is lower than one you have applied.
        activated_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ConsentList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Consent' }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }

    # ---------- Data session ----------

    DataSessionState:
      type: string
      description: |
        Identical on every rail. On rails that deliver already-decrypted data, `READY`/`FETCHED`/`DECRYPTED` are
        recorded with coincident timestamps and `fast_forwarded: true` — the states are real, they just all
        happened at once.
      enum: [REQUESTED, READY, FETCHED, DECRYPTED, DELIVERED, PURGED, FAILED]

    SessionStateReason:
      type: string
      enum:
        - CONSENT_NOT_ACTIVE
        - SESSION_EXPIRED
        - ALL_ACCOUNTS_FAILED
        - FIP_DENIED
        - FIP_TIMEOUT
        - DECRYPT_FAILED
        - UNSUPPORTED_FORMAT
        - UPSTREAM_UNAVAILABLE
        - UPSTREAM_UNMAPPED
        - CANNOT_SERVE

    SessionOrigin:
      type: string
      description: |
        `CALLER_REQUEST` — you asked for it. `VENDOR_INITIATED` — the rail pushed data automatically on consent
        activation and we opened a session to receive it, so that the fetch is visible, metered and evidenced
        like any other.
      enum: [CALLER_REQUEST, VENDOR_INITIATED]

    AccountsSummary:
      type: object
      required: [total, delivered, failed, pending]
      properties:
        total: { type: integer }
        delivered: { type: integer }
        failed: { type: integer }
        pending: { type: integer }

    DataSessionCreateRequest:
      type: object
      required: [consent_id]
      additionalProperties: false
      properties:
        consent_id: { type: string, pattern: '^con_[A-Za-z0-9]{10,32}$' }
        fi_data_range:
          $ref: '#/components/schemas/DateRange'
          description: Must lie within the consent's data range. Defaults to the consent's full range when omitted.
        client_reference: { type: string, maxLength: 128 }
        metadata: { $ref: '#/components/schemas/Metadata' }

    DataSession:
      type: object
      required: [id, object, consent_id, state, origin, aa, custody, fi_data_range, accounts_summary, fast_forwarded, livemode, sequence, requested_at]
      properties:
        id: { type: string, examples: [dsn_01J8ZC5R9F] }
        object: { type: string, const: data_session }
        consent_id: { type: string }
        state: { $ref: '#/components/schemas/DataSessionState' }
        state_reason: { oneOf: [ { $ref: '#/components/schemas/SessionStateReason' }, { type: 'null' } ] }
        origin: { $ref: '#/components/schemas/SessionOrigin' }
        fast_forwarded:
          type: boolean
          description: True when the intermediate states were reached in a single rail event (push delivery).
        aa: { $ref: '#/components/schemas/Aa' }
        custody: { $ref: '#/components/schemas/Custody' }
        fi_data_range: { $ref: '#/components/schemas/DateRange' }
        accounts_summary: { $ref: '#/components/schemas/AccountsSummary' }
        serviceability: { $ref: '#/components/schemas/Serviceability' }
        data:
          type: [object, 'null']
          description: Present once the payload is retrievable.
          properties:
            url: { type: string, format: uri }
            completeness: { type: string, enum: [COMPLETE, PARTIAL] }
            expires_at:
              type: string
              format: date-time
              description: 'Equals `purge_due_at`. After this, retrieval returns `status: PURGED`.'
        client_reference: { type: [string, 'null'] }
        metadata: { $ref: '#/components/schemas/Metadata' }
        livemode: { type: boolean }
        sequence: { type: integer, description: Monotonic per data session. }
        requested_at: { type: string, format: date-time }
        ready_at: { type: [string, 'null'], format: date-time }
        fetched_at: { type: [string, 'null'], format: date-time }
        decrypted_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        first_retrieved_at:
          type: [string, 'null']
          format: date-time
          description: When you first successfully pulled the payload. The custody-handoff record.
        purge_due_at: { type: [string, 'null'], format: date-time }
        purged_at: { type: [string, 'null'], format: date-time }

    DataSessionList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/DataSession' }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }

    # ---------- FI payload ----------

    FiDataStatus:
      type: string
      enum: [PENDING, PARTIAL, AVAILABLE, PURGED]

    FiAccount:
      type: object
      required: [id, status, fi_type]
      properties:
        id: { type: string, description: Lender OS account-batch id. Stable across pages and retrievals. }
        fip_id: { type: [string, 'null'], description: 'The Financial Information Provider, where the rail reports it.' }
        masked_account_number: { type: [string, 'null'] }
        link_ref_number: { type: [string, 'null'] }
        fi_type: { $ref: '#/components/schemas/FiType' }
        status:
          type: string
          enum: [PENDING, DELIVERED, FAILED, PURGED]
        failure_reason:
          type: [string, 'null']
          enum: [FIP_DENIED, FIP_TIMEOUT, DECRYPT_FAILED, UNSUPPORTED_FORMAT, null]
        spec_version:
          type: [string, 'null']
          description: The ReBIT schema version this payload conforms to, as pinned for the serving rail.
        payload_hash:
          type: [string, 'null']
          description: SHA-256 of the payload as delivered. Verify it; it is also what the purge attestation records.
        fetched_at: { type: [string, 'null'], format: date-time }
        payload:
          type: [object, 'null']
          additionalProperties: true
          description: |
            **Raw, untransformed ReBIT FI JSON for this account**, exactly as it left the FIP (decrypted).
            Lender OS normalizes the envelope around it and never the data inside it. Omitted when
            `include_payload=false`, when the account failed, and when the session has been purged.

    FiData:
      type: object
      required: [object, session_id, consent_id, status, aa, custody, accounts, has_more]
      properties:
        object: { type: string, const: fi_data }
        session_id: { type: string }
        consent_id: { type: string }
        status: { $ref: '#/components/schemas/FiDataStatus' }
        aa: { $ref: '#/components/schemas/Aa' }
        custody: { $ref: '#/components/schemas/Custody' }
        fi_data_range: { $ref: '#/components/schemas/DateRange' }
        accounts_summary: { $ref: '#/components/schemas/AccountsSummary' }
        delivered_at: { type: [string, 'null'], format: date-time }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: The purge date. Retrieve before this; afterwards only the attestation remains.
        purge:
          type: [object, 'null']
          description: Present only when `status` is `PURGED`.
          properties:
            attestation_id: { type: string, examples: [att_01J9AA3K7P] }
            executed_at: { type: string, format: date-time }
            accounts_destroyed: { type: integer }
            note:
              type: string
              description: 'Constant: the data was destroyed under the consent''s data-life; the attestation is exportable from the evidence pack.'
        accounts:
          type: array
          items: { $ref: '#/components/schemas/FiAccount' }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }

    # ---------- Webhook events ----------

    EventEnvelope:
      type: object
      description: |
        Connector-family-agnostic by construction: `data.object` is the same Consent or DataSession resource the
        REST API returns. Nothing in this envelope differs between a rail we reach on raw ReBIT and a rail we
        reach through a wrapper.
      required: [id, object, type, api_version, livemode, created_at, occurred_at, sequence, data]
      properties:
        id: { type: string, examples: [evt_01J8ZB4KQ1], description: Deduplicate on this. }
        object: { type: string, const: event }
        type: { $ref: '#/components/schemas/EventType' }
        api_version: { type: string, const: v1 }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time, description: When we emitted the event. }
        occurred_at: { type: string, format: date-time, description: When the underlying transition actually happened. }
        sequence:
          type: integer
          description: Monotonic, gapless per entity. Ignore an event whose sequence you have already surpassed for that entity.

    EventType:
      type: string
      enum:
        - consent.activated
        - consent.rejected
        - consent.revoked
        - consent.paused
        - consent.expired
        - session.data_ready
        - session.data_delivered
        - session.failed

    ConsentEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - type: object
          required: [data]
          properties:
            data:
              type: object
              required: [object]
              properties:
                previous_state: { oneOf: [ { $ref: '#/components/schemas/ConsentState' }, { type: 'null' } ] }
                object: { $ref: '#/components/schemas/Consent' }

    SessionEventDataBase:
      type: object
      required: [object]
      properties:
        previous_state: { oneOf: [ { $ref: '#/components/schemas/DataSessionState' }, { type: 'null' } ] }
        object: { $ref: '#/components/schemas/DataSession' }

    SessionDataReadyEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - type: object
          required: [data]
          properties:
            data:
              allOf:
                - $ref: '#/components/schemas/SessionEventDataBase'
                - type: object
                  required: [data_url, completeness]
                  properties:
                    data_url:
                      type: string
                      format: uri
                      description: Authenticated pull URL. The event itself carries no financial information.
                    completeness: { type: string, enum: [COMPLETE, PARTIAL] }

    SessionDeliveredEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - type: object
          required: [data]
          properties:
            data:
              allOf:
                - $ref: '#/components/schemas/SessionEventDataBase'
                - type: object
                  required: [retrieved_at]
                  properties:
                    retrieved_at: { type: string, format: date-time }
                    accounts_delivered: { type: integer }
                    accounts_failed: { type: integer }

    SessionFailedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - type: object
          required: [data]
          properties:
            data:
              allOf:
                - $ref: '#/components/schemas/SessionEventDataBase'
                - type: object
                  required: [reason]
                  properties:
                    reason: { $ref: '#/components/schemas/SessionStateReason' }
                    retryable:
                      type: boolean
                      description: Whether opening a new data session under the same consent can plausibly succeed.