openapi: 3.0.3
info:
  title: BILRS API
  contact:
    name: BILRS Support
    email: support@bilrs.com
    url: https://bilrs.com
  x-logo:
    url: https://api.bilrs.com/swagger/favicon.svg
    altText: BILRS
  description: |
    BILRS (Bill Remittance Service) — access 1000s of billers, mobile top-ups
    and gift cards around the world through a single integration. Test in the
    sandbox, complete the test cases, and deploy.

    Two API generations are live in parallel:

    * **v2** — the original catalog + payment surface. Catalog endpoints return
      a **bare JSON array** of records; payment/inquiry/wallet endpoints return
      a single JSON object.
    * **v2.1** — S3-backed catalog endpoints that return a **pagination
      envelope** (`Page` / `PageSize` / `TotalPages` / `TotalRecords` + a `Data`
      array, except FX which uses `SettlementCurrencies`). Payment/inquiry
      (amountdue, processpayment, balance, etc.) remain on the v2 paths.

    Most business endpoints carry the outcome in a `ResponseCode` field and
    always return transport status `200`. The exceptions are documented
    per-endpoint: `/v2/webhooks/replay` uses a distinct `ok`/`error` envelope
    with real HTTP status codes, and `/v2/ping` always returns `404`.

    ## Getting started

    1. **Register for the portal.** Your portal is where you generate API
       tokens, allowlist IP addresses, configure webhooks and pull reports.
       Your **sandbox** token is created in the portal's security tab.
    2. **Integrate against the sandbox**, using the reference numbers from the
       test sheets below (arbitrary references are not accepted). See
       *Sandbox environment* for the ways the sandbox differs from production.
    3. **Complete the test cases** and send them to
       [onboarding@bilrs.com](mailto:onboarding@bilrs.com) for review:
       [flag combinations](https://docs.google.com/spreadsheets/d/1Q0MKntVOzHBZcqqV5L-5mPDVknPbN45y/edit),
       [mobile carrier lookup](https://docs.google.com/spreadsheets/d/1yB5TrffDRxPqUK2HzhhjdJ5Qvd0qEnNy/edit),
       [response codes](https://docs.google.com/spreadsheets/d/1q4aJnI1-gJcgbON9zc-OPzN9eGm_SpRy/edit),
       [successful flows](https://docs.google.com/spreadsheets/d/1V3aj7ULK5SxhdwEHvcyKYzZOVF0GFRh3/edit).
    4. **Go live.** Once the integration is validated, your production
       credentials are unlocked in the portal.

    The platform day resets at **00:00 UTC**; transactions may appear in your
    daily reports under a different day than in your local timezone. Domestic
    commissions from successful transactions are credited back to your wallet
    nightly at midnight UTC.

    ## Authentication

    Generate your API token in the portal and send it on every request as a
    bearer token: `Authorization: Bearer {API TOKEN}`. The token is static per
    client — the same token is used for all requests, not generated per
    request. For security we recommend rotating it in the portal roughly every
    six months. Requests are also restricted to your registered IP addresses;
    manage them in the portal's security tab. TLS 1.2 or higher is enforced on
    all endpoints.

    ## Catalogs and caching

    The biller, SKU and IO catalogs change rarely. Cache them locally rather
    than calling the catalog endpoints on every request. Each catalog row
    carries a `CatalogVersion`, and `/v2/echo` returns the current max
    `CatalogVersion`; poll `echo` (e.g. daily) and only re-pull all three
    catalog levels when the version changes. Treat `CatalogVersion` as the
    canonical cache-invalidation signal. There is no separate notification
    when the catalog changes.

    Reading the catalog:

    * **Pagination is mandatory** on the v2.1 catalog endpoints — the catalog
      is too large to load in a single page.
    * A biller may expose **multiple SKUs**: one per fixed denomination, per
      service (e.g. water vs electricity), or per accepted input type.
    * `MinAmount == MaxAmount` means a **fixed-denomination** product; a SKU
      with `Amount > 0` only accepts exactly that amount. If no max amount is
      set, any amount is accepted within the biller's rules.
    * The IO catalog defines the **input references** a biller requires (name,
      numeric/alphanumeric, min/max length). All listed inputs are mandatory.
      BILRS does not validate input format — validate on your side before
      submitting.
    * `BackendFee` / `IsBackendFeePercentage` describe a biller-side fee that
      is debited from your wallet **on top of** the amount you send (bill of
      100 with a 2 backend fee → send 100, wallet debit 102). Never add the
      fee to the payment amount yourself.
    * Country codes are ISO alpha-3; **`WWX`** marks worldwide gift cards that
      are not geo-restricted.
    * Biller logos and country-flag images are available — image URLs are
      valid for one hour and refresh with catalog refreshes.

    ## Transaction flow

    * If `InquiryAvailable = 1` in the SKU catalog, calling `/v2/amountdue`
      first is **mandatory**. Copy `Output1` from the AmountDue response —
      unmodified — into `Input1` of the ProcessPayment request.
    * If `InquiryAvailable = 0`, call `/v2/processpayment` directly with the
      input references defined in the IO catalog.
    * Billers requiring more than one input take them pipe-delimited, sorted
      ascending by the IO catalog's `IOID`: `{IOID1}|{IOID2}|{IOID3}`.
    * `EntityTransactionID` is your **idempotency key**: a matching ID (with
      the same reference, amount and biller) re-sent within 24 hours is
      rejected with `ResponseCode 09` (duplicate transaction).
    * Set a **60-second timeout** on inquiry and payment calls. On timeout, or
      on `ResponseCode 10` (transaction in progress), poll
      `/v2/verifypaymentstatus` — recommended: 30-second interval, 8–10
      attempts. Treat transactions still on code 10 after the polling window
      as indeterminate and escalate for review; do not auto-reject them.
    * `ResponseCode 00` is **final** — payments cannot be cancelled or
      recalled. The biller may still take up to `DaysToPost` days
      (`BusinessDays` flags business vs calendar days) to reflect the payment
      on the customer's account; surface this on your receipt.
    * **Past-due / multi-bill accounts** (`PastDuePaymentAllowed = 1`): some
      billers combine all outstanding bills (`BillAmountDue` =
      `TotalAmountDue`, payable in one go); others require paying each bill
      individually (`BillsDue > 1`, `BillAmountDue` is the next payable bill —
      typically oldest first — and the order cannot be chosen). A **negative**
      amount due means the account is in credit; no flow change is needed.
    * **PIN products** (gift cards, some mobile recharges) require no user
      inputs. Always display `TicketCaption` — it carries the PIN/serial
      number, or for PIN-based recharges a JSON payload with `pinNumber`,
      `controlNumber` and redemption `instructions`.

    ## Response codes

    `00` is success and `10` is pending; every other code is a rejection.

    | Code | Message | Description |
    |------|---------|-------------|
    | 00 | Process complete | The transaction was successfully processed. |
    | 01 | Authentication error | The token used in the request is invalid. |
    | 02 | Insufficient funds | Your wallet has insufficient funds to cover the payment. Resending returns the same code — top up your prefunding first. |
    | 03 | Invalid biller | The `BillerID` does not exist in the current catalog. |
    | 04 | Biller unavailable | The biller was unable to process the request. |
    | 05 | Request timeout | The biller did not respond to the request. |
    | 06 | Invalid SKU | The SKU does not exist in the current catalog. |
    | 07 | Invalid reference | The inputs sent in the request are incorrect. |
    | 08 | Invalid amount | The amount is invalid (e.g. partial or excess payment not permitted by the biller). |
    | 09 | Duplicate transaction | The payment is seen as a duplicate by BILRS or the biller. |
    | 10 | Transaction in progress | The payment is still being posted to the biller; it will settle to a final status. |
    | 11 | Inquiry not available for SKU | The biller has no balance-inquiry capability for this SKU. |
    | 12 | Past due payment not available for SKU | The biller does not accept payments past the due date. |
    | 13 | Sender information incomplete | The sender's information on the payment request is incomplete. |
    | 14 | Error processing transaction | BILRS was unable to process the transaction. |
    | 15 | Transaction not found | The transaction inquired via VerifyPaymentStatus was not found. |
    | 16 | Payments for reference exceeded | The account has received the maximum number of payments for the day. |
    | 17 | Account status invalid | The account is inactive or blocked with the biller. |
    | 18 | Amount out of allowed range | The amount is below the minimum or above the maximum allowed for this SKU. |
    | 19 | Data not found | The search criteria returned no records. |
    | 20 | Request format invalid | The request does not comply with the expected structure. |

    ## FX rates

    Rates are fixed for 24 hours starting at 23:30 UTC, seven days a week, and
    use six decimal places — fixed so a bill costs the customer the same in
    the morning as in the afternoon. `DailyFXRate` returns the rate locked for
    the day (`FXDate`); the `IndicativeFXRate` on an AmountDue response is a
    point-in-time reference only, not locked in. Pairings only appear for base
    currencies with available billers. Supported settlement currencies: USD,
    CAD, GBP, EUR, AED, SAR.

    ## Webhooks (delivery to your listener)

    BILRS POSTs runtime payment events (e.g. a `ResponseCode 10` settling to a
    final status) to your registered webhook URL. Your listener must validate
    and acknowledge them:

    * **Security headers** on every delivery:
      * `X-Bilrs-Signature` — HMAC of the raw request body, keyed with your
        webhook secret.
      * `X-Bilrs-Timestamp` — unix time the delivery was signed; reject stale
        deliveries to prevent replay.
      * `X-Bilrs-Signature-Version` — signature scheme version.
    * **Verify** by recomputing the HMAC over the exact raw body and comparing
      against `X-Bilrs-Signature` (constant-time). Reject on mismatch.
    * **Acknowledge** with HTTP `2xx`. Non-2xx / timeout responses are retried
      on a backoff schedule; repeated failures trip a per-endpoint circuit
      breaker and undelivered events are routed to a DLQ with SNS alerting.
      Use `/v2/webhooks/replay` to re-drive events once your endpoint
      recovers. For business-critical reconciliation, use
      `/v2/verifypaymentstatus` as a fallback for any transaction that did
      not receive a webhook confirmation.

    Webhooks are configured in the portal (including your own token) and are
    available in both sandbox and production; the sandbox starts sending test
    data the day after configuration. Three event types are supported:

    * **`bill_account_notification`** — a daily process re-runs balance
      inquiries on references you previously transacted on successfully and
      notifies you when a new amount is due. Prefer this over polling
      AmountDue — billers are sensitive to inquiry overuse (target at most a
      3:1 inquiry-to-transaction ratio).

      ```json
      {
        "Type": "bill_account_notification",
        "Data": {
          "EntityCustomerID": "",
          "BillerID": "",
          "SKU": "",
          "Inputs": "",
          "Amount": "",
          "BillDueDate": ""
        }
      }
      ```

    * **`payment_notification`** — an account you previously inquired on was
      paid on the platform by another client, so you do not try to charge it
      again.
    * **`selective_payment_processed`** — for billers that let the user select
      which specific bills to pay (currently Dubai Police): each selected bill
      is paid individually and gets its own webhook.

      ```json
      {
        "Type": "selective_payment_processed",
        "Data": {
          "BillID": "",
          "EntityTransactionId": "",
          "TransactionId": "",
          "BillerID": "",
          "SKU": "",
          "ResponseCode": "",
          "ResponseMessage": "",
          "ResponseDatetime": ""
        }
      }
      ```

    ## Sandbox environment

    * The sandbox does **not store transactions**: all responses are final, so
      `VerifyPaymentStatus` returns `19 – Data not found`, and sandbox
      transactions do not appear in portal reports.
    * Only the reference numbers from the published test sheets are accepted.
    * The sandbox wallet currency is **USD** (production uses your wallet
      currency); FX rates are available for **AED pairings only** and are not
      live production rates. A working `DailyFXRate` example uses
      `FXDate: "2025-04-30"`.

    ## Tooling

    * **Postman collection** —
      [download](https://api.bilrs.com/swagger/postman_collection.json),
      import into Postman, then set the collection's `TOKEN` variable to the
      token from your portal (the `BASEPATH` variable defaults to
      `https://api.bilrs.com/v2.1/`).
    * **OpenAPI spec** — this document is a standard OpenAPI 3.0 file
      ([download](https://api.bilrs.com/swagger/openapi.yaml)); load it into
      any OpenAPI viewer or client generator.
    * **NOL Direct top-up SDK (UAE)** — an Android SDK for direct NOL top-up
      using the device's NFC capabilities instead of tapping in at a metro
      gate. Contact [support@bilrs.com](mailto:support@bilrs.com) to
      integrate.

    ## Integration best practices

    * Use dynamic input labels from the IO catalog rather than a static
      "Account number" for every biller — customers look for the exact
      reference name printed on their bill — and validate length/format
      client-side before submitting.
    * Tell the customer when the payment will reflect with the biller
      (`DaysToPost` / `BusinessDays`). It reduces status inquiries and is a
      regulatory requirement in some markets (e.g. the US).
    * Build trust with familiar iconography — country flags and biller logos
      (available on request, along with sample bills).
    * Blocking partial, excess or past-due payments is possible but affects
      volume — customers commonly pay partially or in excess when they have
      money available, particularly for satellite and cable TV.
    * Month-on-month engagement: individual bill-payment reminders driven by
      the `bill_account_notification` webhook can drive over 80% monthly
      engagement.

    ## Getting help

    * Integration and technical support:
      [support@bilrs.com](mailto:support@bilrs.com)
    * Test-case review and go-live:
      [onboarding@bilrs.com](mailto:onboarding@bilrs.com)
    * [BILRS homepage](https://bilrs.com) ·
      [Privacy policy](https://bilrs.com/privacy-policy/)
  version: "2.1.0"

servers:
  - url: https://api.bilrs.com
    description: BILRS API

security:
  - clientKey: []

tags:
  - name: Payments
    description: Inquiry and payment submission.
  - name: Catalog v2.1
    description: v2.1 paginated, S3-backed catalog lookups.
  - name: Wallet
    description: Wallet balance and FX.
  - name: Lookup
    description: Mobile carrier and bill-notification lookups.
  - name: Webhooks
    description: Webhook replay.
  - name: System
    description: Echo / endpoint validation.

paths:
  # ----------------------------- Payments -----------------------------
  /v2/amountdue:
    post:
      tags: [Payments]
      operationId: amountDue
      summary: Inquire amount due for a biller account
      description: >
        Resolves the biller/SKU, invokes the selected vendor, and returns the
        current amount due (and any biller-supplied bill details) for the account
        identified by `Inputs`. Requires the SKU to support inquiry (otherwise
        `ResponseCode` `11`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InquiryRequest' }
            example:
              EntityTransactionID: "1000"
              EntityCustomerID: "1"
              BillerID: "784000000000012"
              SKU: "00000000000000000001"
              Inputs: "01003649211"
      responses:
        '200':
          description: Inquiry result (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InquiryResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:32:10"
                TransactionID: "550e8400-e29b-41d4-a716-446655440000"
                EntityTransactionID: "1000"
                BillAmountDue: "150.00"
                BillDueDate: "2026-07-15"
                BillsDue: 1
                TotalAmountDue: "150.00"
                CustomerName: "Ahmed Ali"
                Output1: "Invoice #INV-001"
                Output2: "Dubai, UAE"
                SettlementCurrency: "AED"
                BaseCurrency: "AED"
                IndicativeFXRate: "1.0000"
                Bills:
                  - Id: "BILL001"
                    Amount: "150.00"

  /v2/processpayment:
    post:
      tags: [Payments]
      operationId: processPayment
      summary: Submit a payment for processing
      description: >
        Validates wallet/biller/SKU/FX/balance, debits the wallet, invokes the
        vendor, records the transaction in `TRX_BILRS`, and enqueues webhook
        delivery.


        **Client behaviour — react to `ResponseCode`, do not blindly retry:**

        * **`00`** — the only final success. The payment completed.

        * **`10`** (in progress) — the payment was accepted and is still settling.
          Do **not** resubmit (that risks a double charge). Poll
          `/v2/verifypaymentstatus` with the same `EntityTransactionID`, or wait
          for the webhook event, until you see a final code.

        * **`09`** (duplicate) — this `EntityTransactionID` was already used within
          the ~24h dedup window. Don't retry with the same ID; check the original
          via `/v2/verifypaymentstatus`. Use a fresh unique ID per payment.

        * **`15`** and other error codes — terminal failures; surface the
          `ResponseMessage` and start a new payment with a new `EntityTransactionID`
          if appropriate.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentRequest' }
            example:
              EntityTransactionID: "1000"
              EntityCustomerID: "1"
              BillerID: "784000000000012"
              SKU: "00000000000000000001"
              Inputs: "01003649211"
              Amount: "150.00"
      responses:
        '200':
          description: Payment result (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentResponse' }
              examples:
                success:
                  summary: Completed (00)
                  value:
                    ResponseCode: "00"
                    ResponseMessage: "Process complete"
                    ResponseDateTime: "2026-06-25 14:32:10"
                    ConfirmationNumber: "CONF-123456789"
                    TicketCaption: "Reference: TKT-001"
                    TransactionID: "550e8400-e29b-41d4-a716-446655440000"
                    EntityTransactionID: "1000"
                    SettlementCurrency: "AED"
                    BaseCurrency: "AED"
                    FXRate: "1.0000"
                    WalletDeductedAmount: "150.00"
                pending:
                  summary: In progress (10) — poll verifypaymentstatus
                  value:
                    ResponseCode: "10"
                    ResponseMessage: "Transaction in progress"
                    ResponseDateTime: "2026-06-25 14:32:10"
                    ConfirmationNumber: ""
                    TicketCaption: ""
                    TransactionID: ""
                    EntityTransactionID: "1000"
                    SettlementCurrency: ""
                    BaseCurrency: ""
                    FXRate: ""

  /v2/verifypaymentstatus:
    post:
      tags: [Payments]
      operationId: verifyPaymentStatus
      summary: Verify the status of a previously submitted payment
      description: >
        Looks up a prior payment by `EntityTransactionID` and returns its current
        outcome. `ResponseCode` `10` = still pending (poll again with backoff, or
        await the webhook — don't resubmit the payment), `00` = settled
        successfully, `19` = not found, other codes = terminal failure.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyPaymentStatusRequest' }
            example:
              EntityTransactionID: "1000"
      responses:
        '200':
          description: Current transaction status (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:32:15"
                ConfirmationNumber: "CONF-123456789"
                TicketCaption: "Reference: TKT-001"
                TransactionID: "550e8400-e29b-41d4-a716-446655440000"
                EntityTransactionID: "1000"
                SettlementCurrency: "AED"
                BaseCurrency: "AED"
                FXRate: "1.0000"

  # ----------------------------- Wallet -----------------------------
  /v2/balance:
    post:
      tags: [Wallet]
      operationId: getBalance
      summary: Get wallet balance for a currency
      description: Returns the client's wallet balance for the requested `Currency`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BalanceRequest' }
            example:
              Currency: "USD"
      responses:
        '200':
          description: Wallet balance (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BalanceResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:00"
                Currency: "USD"
                Wallet: "Primary Wallet"
                AvailableBalance: 5000.50
                CreditLimit: 10000.00
                CreditUsed: 4500.00
                CreditLeft: "5500.00"
                isActive: true

  /v2/dailyfxrate:
    post:
      tags: [Wallet]
      operationId: dailyFxRateV2
      summary: Daily FX rates (v2)
      description: >
        Returns FX rates for the requested `BaseCurrency`, optionally narrowed to a
        single `SettlementCurrency`. Rates are nested per settlement currency, then
        per biller-type/country.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FxRateRequestV2' }
            example:
              BaseCurrency: "AED"
              SettlementCurrency: "USD"
      responses:
        '200':
          description: FX rates (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FxRateResponseV2' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:00"
                FXDate: "2026-06-25"
                BaseCurrency: "AED"
                SettlementCurrencies:
                  - SettlementCurrency: "USD"
                    BillerTypes:
                      - BillerType: "Utilities"
                        Country: "AE"
                        FXRate: "0.272603"
                      - BillerType: "Top-Up"
                        Country: "AE"
                        FXRate: "0.272540"

  /v2.1/dailyfxrate:
    post:
      tags: [Catalog v2.1]
      operationId: dailyFxRateV21
      summary: Daily FX rates (v2.1, paginated, supports historic date)
      description: >
        Paginated FX rates. `BaseCurrency` is required (upper-cased server-side).
        Optional `SettlementCurrency`, `FXDate` (historic), and pagination. Unlike
        v2, each settlement row carries mid-market vs vendor rate and the bps
        spread.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FxRateRequestV21' }
            example:
              BaseCurrency: "USD"
              Page: 1
              PageSize: 50
      responses:
        '200':
          description: Paginated FX rates (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FxRateResponseV21' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 2
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                BaseCurrency: "USD"
                FXDate: "2026-06-25"
                SettlementCurrencies:
                  - SettlementCurrency: "EUR"
                    Country: "FRA"
                    BillerType: "Top-Up"
                    MidMarketRate: "0.9200"
                    VendorRate: "0.9150"
                    Bps: 50
                  - SettlementCurrency: "GBP"
                    Country: "GBR"
                    BillerType: "Billpay"
                    MidMarketRate: "0.7850"
                    VendorRate: "0.7820"
                    Bps: 30

  /v2/billnotification:
    post:
      tags: [Lookup]
      operationId: billNotificationV2
      summary: Bill notifications for a date (v2)
      description: >
        Returns bills registered for notification on `NotificationDate` (defaults
        to today). Single object with a `Bills` array.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BillNotificationRequest' }
            example:
              NotificationDate: "2026-06-25"
      responses:
        '200':
          description: Bill notifications (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillNotificationResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:00"
                NotificationDate: "2026-06-25"
                Bills:
                  - EntityCustomerID: "CUST-12345"
                    BillerID: "784000000000012"
                    SKU: "DU_POSTPAID_RESIDENTIAL"
                    Inputs: "0501234567"
                    Amount: "150.50"
                    BillDueDate: "2026-07-05"

  /v2/mobilecarrierlookup:
    post:
      tags: [Lookup]
      operationId: mobileCarrierLookup
      summary: Look up mobile carrier / billers by phone number
      description: >
        Routes by mobile prefix. `971…` numbers resolve via the MBME lookup Lambda
        (+ `LookupVendor`); everything else via the PrePayNation lookup, mapped
        against `VendorSKU`. A leading `+` is stripped; a number starting with `0`
        returns `ResponseCode` `07`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MobileCarrierLookupRequest' }
            example:
              MobileNumber: "971501234567"
      responses:
        '200':
          description: Matching carrier / biller / SKUs (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MobileCarrierLookupResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                MobileNumber: "971501234567"
                Country:
                  CountryCode: "AE"
                  CountryName: "United Arab Emirates"
                Biller:
                  BillerID: "356000000000230"
                  BillerName: "DU Telecom"
                SKU:
                  - SKU: "00000000000000000001"
                    DaysToPost: 2
                    CatalogVersion: "2019081701"
                    PartialPaymentAllowed: 0
                    PastDuePaymentAllowed: 0
                    BillerID: "356000000000230"
                    Currency: "AED"
                    Amount: 0.0
                    MinAmount: null
                    MaxAmount: null
                    ResponseMessage: "Process complete"
                    ResponseCode: "00"
                    BusinessDays: 1
                    InquiryAvailable: 1
                    ExcessPaymentAllowed: 0
                    Type: "Service"
                    Description: "Mobile Postpaid"

  # -------------------------- Catalog v2.1 --------------------------
  /v2.1/iocatalog:
    post:
      tags: [Catalog v2.1]
      operationId: ioCatalogV21
      summary: IO field catalog (v2.1, paginated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaginatedCatalogRequest' }
            example:
              Page: 1
              PageSize: 50
              BillerID: "BILLER#MOB001"
      responses:
        '200':
          description: Paginated IO field definitions.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedResponse' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 1
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                Data:
                  - SKU: "PREPAID#TOPUP"
                    ValidLengths: "4,5,6"
                    CatalogVersion: "v2.1"
                    Datatype: "numeric"
                    BillerID: "BILLER#MOB001"
                    MaxLength: "6"
                    MinLength: "4"
                    Name: "Mobile Top-Up"
                    Description: "Prepaid mobile top-up service"
                    Operation: "INQUIRY"
                    Type: "TOPUP"
                    IOID: "IO#12345"

  /v2.1/skucatalog:
    post:
      tags: [Catalog v2.1]
      operationId: skuCatalogV21
      summary: SKU catalog (v2.1, paginated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaginatedCatalogRequest' }
            example:
              Page: 1
              PageSize: 50
              BillerID: "BILLER#UTIL001"
      responses:
        '200':
          description: Paginated SKU catalog.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SkuCatalogResponseV21' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 1
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                Data:
                  - BillerID: "BILLER#UTIL001"
                    SKU: "ELECTRICITY"
                    Validity: "30 days"
                    BackendFee: "0.50"
                    IsBackendFeePercentage: false
                    MoreInformation: "Residential electricity payments"
                    Tax: "0.15"

  /v2.1/billercatalog:
    post:
      tags: [Catalog v2.1]
      operationId: billerCatalogV21
      summary: Biller catalog (v2.1, paginated, includes sample-bill presign)
      description: >
        `BillerType` / `BillerSubType` filters require the client to have
        `support_online_catalog` enabled.


        **`SampleBills`** — each biller row carries a `SampleBills` array
        (always present; `[]` when the biller has none). Each entry has:

        * `FileName` — e.g. `"1.png"`.
        * `FileType` — MIME type, always an image: `image/png`, `image/webp`, or
          `image/jpeg`. Source PDFs are rendered to images on upload and every
          file is optimised to under 1 MB, so the API **never** returns a PDF —
          render by `FileType`, don't infer from the URL.
        * `Key` — stable, permanent S3 object key (e.g.
          `sample_bills/MEX/484000000000200/1.png`). Safe to cache forever.
        * `Url` — a **presigned GET URL that expires after ~1 hour**.


        Caching: `Url` is short-lived because the bucket is private — unlike
        `Logo`, which is a permanent public URL, do **not** treat `Url` as
        stable. Cache `Key`/`FileName`/`FileType` (which never expire), lazy-load
        the image only when a customer opens the sample, and if your cached `Url`
        is older than ~1 hour re-fetch the catalog to obtain a freshly signed
        `Url` for that `Key`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaginatedBillerCatalogRequest' }
            example:
              Page: 1
              PageSize: 50
              CountryCode: "AUS"
      responses:
        '200':
          description: Paginated biller catalog.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedResponse' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 1
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                Data:
                  - BillerType: "Billpay"
                    BillerSubType: "Utility"
                    CatalogVersion: "v2.1"
                    CountryCode: "AUS"
                    BillerID: "BILLER#AUS_ELEC"
                    CountryName: "Australia"
                    BillerDescription: "National electricity provider"
                    BillerName: "ElectroCorp"
                    BackendFee: "2.50"
                    IsBackendFeePercentage: false
                    Logo: "https://s3.amazonaws.com/billers/auselectric-logo.png"
                    SampleBills:
                      - FileName: "1.png"
                        FileType: "image/png"
                        Key: "sample_bills/AUS/BILLER_AUS_ELEC/1.png"
                        Url: "https://s3.amazonaws.com/presigned-url?X-Amz-Expires=3600"

  /v2.1/countrycatalog:
    post:
      tags: [Catalog v2.1]
      operationId: countryCatalogV21
      summary: Country list & biller-type mappings (v2.1)
      description: >
        Requires the client to have `support_online_catalog` enabled, otherwise
        `ResponseCode` `01`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaginatedRequest' }
            example:
              Page: 1
              PageSize: 50
      responses:
        '200':
          description: Countries and their biller-type mappings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedResponse' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 1
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                Data:
                  - CountryCode: "AUS"
                    CountryName: "Australia"
                    FlagIcon: "https://s3.amazonaws.com/flags/AUS.png"
                    BillerTypes: ["Billpay", "Top-Up"]
                    ArabicName: "أستراليا"

  /v2.1/billnotification:
    post:
      tags: [Catalog v2.1]
      operationId: billNotificationV21
      summary: Bill notification catalog (v2.1, paginated)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaginatedBillNotificationRequest' }
            example:
              NotificationDate: "2026-06-25"
              Page: 1
              PageSize: 50
      responses:
        '200':
          description: Paginated bill notification entries.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginatedResponse' }
              example:
                Page: 1
                PageSize: 50
                TotalPages: 1
                TotalRecords: 1
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:45"
                Data:
                  - EntityCustomerID: "CUST#12345"
                    BillerID: "BILLER#UTIL001"
                    SKU: "ELECTRICITY"
                    Inputs: "ACC123456"
                    Amount: "150.50"
                    BillDueDate: "2026-07-15"

  # ----------------------------- Webhooks -----------------------------
  /v2/webhooks/replay:
    post:
      tags: [Webhooks]
      operationId: webhookReplay
      summary: Replay past webhook deliveries
      description: >
        Re-enqueues webhook deliveries matching a time window (and optional type/id
        filters) to the client's configured webhook URL. The window is capped at
        **7 days**; at most **10,000** deliveries are replayed per request
        (`truncated: true` if more matched). Per-client cooldown is **60 seconds**.


        **This endpoint does NOT use the `ResponseCode` envelope** — it returns an
        `ok`/`error` body with real HTTP status codes. Requires async webhook
        delivery to be enabled (`WEBHOOK_USE_SQS`), otherwise `503`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookReplayRequest' }
            example:
              from: "2026-06-18T00:00:00Z"
              to: "2026-06-25T00:00:00Z"
              event_types: ["payment_notification"]
      responses:
        '200':
          description: Replay accepted (may report truncation).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayResponse' }
              example:
                ok: true
                matched: 1250
                replayed: 1250
                errors: 0
                truncated: false
                by_type:
                  payment_notification: 1250
                max_events_per_request: 10000
        '400':
          description: Invalid window or parameters.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayError' }
              example:
                ok: false
                error: "replay window exceeds the 7-day maximum"
        '401':
          description: Authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayError' }
              example: { ok: false, error: "unauthorized" }
        '409':
          description: No webhook URL / signing secret configured for this environment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayError' }
        '429':
          description: Rate limited (60s per-client cooldown).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayError' }
              example:
                ok: false
                error: "replay requests are rate-limited"
                retry_after_seconds: 45
        '503':
          description: Async webhook delivery disabled (WEBHOOK_USE_SQS off).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookReplayError' }

  # ----------------------------- System -----------------------------
  /v2/echo:
    post:
      tags: [System]
      operationId: echo
      summary: Auth-checked round trip / catalog version probe
      description: >
        Verifies the API key and returns the current max catalog version. Useful
        as a connectivity + auth smoke test.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
            example: {}
      responses:
        '200':
          description: Echo result (check `ResponseCode`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EchoResponse' }
              example:
                ResponseCode: "00"
                ResponseMessage: "Process complete"
                ResponseDateTime: "2026-06-25 14:30:00"
                CatalogVersion: "5"

  /v2/ping:
    get:
      tags: [System]
      operationId: ping
      summary: Endpoint validation (always 404)
      description: >
        Placeholder/fallback handler — **always returns HTTP 404** with a fixed
        message. It is not a health check and requires no auth.
      security: []
      responses:
        '404':
          description: Always returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example:
                message: "Validate the endpoint you are trying to reach"

components:
  securitySchemes:
    clientKey:
      type: http
      scheme: bearer
      description: >
        Per-client API key sent as `Authorization: Bearer <key>`. Use the
        **live** key for production traffic or the **sandbox** key to hit the
        sandbox flow (resolved server-side from the key). The key decrypts to
        `{clientId, userPoolId, sandbox, timestamp}` and is validated against the
        `Client` table. A bad/missing key yields `ResponseCode` `01`
        (Authentication error), or HTTP `401` on `/v2/webhooks/replay`.


        **IP allowlisting (live traffic only).** In addition to the API key, BILRS
        enforces a per-client source-IP allowlist on **live** requests. The caller
        IP is taken from the **`X-Forwarded-For`** request header (populated by the
        BILRS edge/load balancer from the TCP source — you do not set it yourself;
        any value you send is overwritten) and matched against your client's
        allowlist. Requirements:

          * Manage your allowlist yourself in the **client portal** under
            **Security → Whitelists** ("Add IP") — no BILRS ticket needed. Add
            each production egress IP you call from.
          * A live client with **no** allowlisted IPs is rejected.
          * If the resolved source IP is not in the allowlist, the request fails
            with `ResponseCode` `01` (same as an auth failure).
          * **Sandbox** traffic skips the IP check entirely.

  schemas:
    ResponseCode:
      type: string
      description: |
        Application outcome code. `00` = success; anything else is a non-fatal
        business outcome (HTTP is still `200`). Code `10` is **pending** — the
        transaction was accepted and is still in progress; poll
        `verifypaymentstatus` for the final outcome rather than retrying.

        | Code | Meaning |
        |------|---------|
        | `00` | Process complete |
        | `01` | Authentication error |
        | `02` | Insufficient funds |
        | `03` | Invalid biller |
        | `04` | Biller unavailable |
        | `05` | Request timeout |
        | `06` | Invalid SKU |
        | `07` | Invalid reference |
        | `08` | Invalid amount |
        | `09` | Duplicate transaction |
        | `10` | Transaction in progress (**pending** — poll for status) |
        | `11` | Inquiry not available for SKU |
        | `12` | Past due payment not available for SKU |
        | `13` | Sender information incomplete |
        | `14` | Error processing transaction |
        | `15` | Transaction not found |
        | `16` | Payments for reference exceeded |
        | `17` | Account status invalid |
        | `18` | Amount out of allowed range |
        | `19` | Data not found |
        | `20` | Request format invalid |
      enum:
        - '00'
        - '01'
        - '02'
        - '03'
        - '04'
        - '05'
        - '06'
        - '07'
        - '08'
        - '09'
        - '10'
        - '11'
        - '12'
        - '13'
        - '14'
        - '15'
        - '16'
        - '17'
        - '18'
        - '19'
        - '20'
      example: '00'

    ResponseEnvelope:
      type: object
      description: Common response wrapper used by most endpoints.
      required: [ResponseCode, ResponseMessage, ResponseDateTime]
      properties:
        ResponseCode:
          $ref: '#/components/schemas/ResponseCode'
        ResponseMessage:
          type: string
          example: Process complete
        ResponseDateTime:
          type: string
          description: UTC, format `YYYY-MM-DD HH:MM:SS`.
          example: '2026-06-25 14:30:00'

    # ---- Requests ----
    InquiryRequest:
      type: object
      required: [BillerID, SKU, Inputs, EntityTransactionID]
      properties:
        BillerID:
          type: string
          description: Biller identifier.
          example: '784000000000012'
        SKU:
          type: string
          description: Product/SKU code under the biller.
          example: '00000000000000000001'
        Inputs:
          type: string
          description: >
            Account/customer identifier(s) the biller requires (e.g. account
            number / mobile), composed from the biller's IO field catalog
            (`/v2.1/iocatalog`).


            **Composition rules (caller-side — BILRS does not reorder for you):**

            * **Single input field** — send the raw value (e.g.
              `"01003649211"`).

            * **Multiple input fields** — concatenate the per-field values with a
              pipe `|`, ordered **ascending by each field's `IOID`** from the IO
              catalog. Example: fields `IOID 1` = `"0501234567"` and `IOID 2` =
              `"DUB"` → `Inputs: "0501234567|DUB"`. Order matters; the values are
              split on `|` and matched to fields by IOID position.
          example: '01003649211'
        EntityTransactionID:
          type: string
          description: >
            Caller-side unique transaction reference. Generate a **fresh unique
            value (e.g. a UUID) per event** — one for each inquiry and a separate
            one for each payment; do not reuse across calls. It is used for
            duplicate detection (a repeat within ~24h returns `ResponseCode 09`)
            and is the key you pass to `/v2/verifypaymentstatus`, so unique IDs
            are what make troubleshooting and reconciliation possible.
          example: '1000'
        EntityCustomerID:
          type: string
          description: Optional caller-side customer reference.
          example: '1'

    PaymentRequest:
      description: >
        Inherits all `InquiryRequest` fields (including the `Inputs` composition
        rules) and adds `Amount`.


        **`Inputs` for payment depends on the SKU's `InquiryAvailable` flag**
        (from `/v2.1/iocatalog` / SKU catalog):

        * **`InquiryAvailable = "1"`** — you MUST first call `/v2/amountdue` and
          send back the `Output1` value it returned as `Inputs` here. (`Output1`
          carries the biller's resolved bill/account token from the inquiry;
          paying with the raw customer reference instead may be rejected or
          misposted.)

        * **`InquiryAvailable = "0"`** — no inquiry step exists; send the raw
          customer reference, composed with the same pipe / IOID-order rules as
          `InquiryRequest.Inputs`.
      allOf:
        - $ref: '#/components/schemas/InquiryRequest'
        - type: object
          required: [Amount, EntityCustomerID]
          properties:
            Amount:
              type: string
              description: Payment amount (string-encoded decimal) in biller currency.
              example: '150.00'
            BillIds:
              type: array
              description: Optional bill ids for batch processing.
              items: { type: string }

    VerifyPaymentStatusRequest:
      type: object
      required: [EntityTransactionID]
      properties:
        EntityTransactionID:
          type: string
          description: The caller reference used at payment time.
          example: '1000'

    BalanceRequest:
      type: object
      required: [Currency]
      properties:
        Currency:
          type: string
          description: 3-letter wallet currency code.
          example: USD

    FxRateRequestV2:
      type: object
      required: [BaseCurrency]
      properties:
        BaseCurrency:
          type: string
          example: AED
        SettlementCurrency:
          type: string
          description: Optional single settlement currency to filter to.
          example: USD
        FXDate:
          type: string
          format: date
          description: Optional date (YYYY-MM-DD); omit for today.

    FxRateRequestV21:
      description: >
        Paginated like the catalog endpoints — loop `Page: 1..TotalPages` to load
        all rates; `PageSize` defaults to 50, max 1000.
      allOf:
        - $ref: '#/components/schemas/FxRateRequestV2'
        - type: object
          properties:
            Page: { type: integer, minimum: 1, default: 1 }
            PageSize:
              type: integer
              description: Records per page. Min/default 50, max 1000.
              minimum: 50
              maximum: 1000
              default: 50

    BillNotificationRequest:
      type: object
      properties:
        NotificationDate:
          type: string
          format: date
          description: Defaults to today if omitted.
          example: '2026-06-25'

    MobileCarrierLookupRequest:
      type: object
      required: [MobileNumber]
      properties:
        MobileNumber:
          type: string
          description: >
            Full mobile number incl. country prefix. A leading `+` is stripped; a
            number starting with `0` is rejected (`ResponseCode` `07`). This
            endpoint takes the raw number directly — the IOID / pipe-separated
            `Inputs` composition used by inquiry/payment does **not** apply here.
          example: '971501234567'

    PaginatedRequest:
      type: object
      description: >
        Pagination envelope shared by all v2.1 catalog endpoints. To load a full
        catalog you MUST loop: start at `Page: 1`, read `TotalPages` from the
        response, and keep requesting `Page: 2..TotalPages` until exhausted — a
        single call returns only one page. `PageSize` defaults to **50** and is
        capped at **1000**; request the max page size to minimise round-trips.
      properties:
        Page: { type: integer, minimum: 1, default: 1 }
        PageSize:
          type: integer
          description: Records per page. Min/default 50, max 1000.
          minimum: 50
          maximum: 1000
          default: 50

    PaginatedCatalogRequest:
      allOf:
        - $ref: '#/components/schemas/PaginatedRequest'
        - type: object
          properties:
            BillerID: { type: string }
            SKU: { type: string, description: 'Prefix match (startswith).' }

    PaginatedBillerCatalogRequest:
      allOf:
        - $ref: '#/components/schemas/PaginatedRequest'
        - type: object
          properties:
            BillerID: { type: string }
            CountryCode: { type: string }
            BillerType:
              type: string
              description: Requires `support_online_catalog`.
            BillerSubType:
              type: string
              description: Requires `support_online_catalog`.

    PaginatedBillNotificationRequest:
      allOf:
        - $ref: '#/components/schemas/PaginatedRequest'
        - type: object
          properties:
            NotificationDate:
              type: string
              format: date
              description: Defaults to today if omitted.

    WebhookReplayRequest:
      type: object
      required: [from, to]
      description: >
        Time window (and optional filters) to replay. `from`/`to` accept unix
        seconds or ISO-8601 timestamps; the window must be ≤ 7 days and `to ≥ from`.
      properties:
        from:
          type: string
          description: Unix seconds or ISO-8601.
          example: '2026-06-18T00:00:00Z'
        to:
          type: string
          description: Unix seconds or ISO-8601.
          example: '2026-06-25T00:00:00Z'
        event_types:
          type: array
          description: Optional whitelist of webhook types.
          items: { type: string }
        event_ids:
          type: array
          description: Optional whitelist of delivery ids.
          items: { type: string }

    # ---- Responses ----
    InquiryResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            TransactionID: { type: string }
            EntityTransactionID: { type: string }
            BillAmountDue: { type: string }
            BillDueDate: { type: string }
            BillsDue: { type: integer }
            TotalAmountDue: { type: string }
            CustomerName: { type: string }
            Output1:
              type: string
              description: >
                Biller-resolved bill/account token. For SKUs with
                `InquiryAvailable = "1"`, pass this value back as `Inputs` on the
                subsequent `/v2/processpayment` call (see `PaymentRequest`).
            Output2: { type: string }
            SettlementCurrency: { type: string }
            BaseCurrency: { type: string }
            IndicativeFXRate: { type: string }
            Bills:
              type: array
              items:
                type: object
                properties:
                  Id: { type: string }
                  Amount: { type: string }

    PaymentResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            ConfirmationNumber: { type: string }
            TicketCaption:
              type: string
              nullable: true
              description: >
                Vendor-supplied receipt text, passed through verbatim from the
                biller/vendor response (may be empty or `null`). For most utility
                billers this is a plain reference string. For PIN-based products
                (gift cards, mobile top-ups) the vendor returns an **escaped,
                stringified JSON** object here — parse it as JSON to extract the
                PINs, e.g.
                `{"pins":[{"pinNumber":"...","controlNumber":"...","expirationDate":"... | null"}],"instructions":"..."}`.
                It remains a JSON string on the wire, not a nested object.
            TransactionID: { type: string }
            EntityTransactionID: { type: string }
            SettlementCurrency: { type: string }
            BaseCurrency: { type: string }
            FXRate: { type: string }
            WalletDeductedAmount:
              type: string
              description: Present on success (`00`).

    BalanceResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            Currency: { type: string }
            Wallet: { type: string }
            # Heads-up: the wallet's stored numeric columns are emitted RAW from
            # DynamoDB, so AvailableBalance/CreditLimit/CreditUsed arrive as JSON
            # numbers. CreditLeft is computed and str()-wrapped, so it arrives as
            # a string. Don't assume a uniform money type across these fields.
            AvailableBalance: { type: number, example: 5000.50 }
            CreditLimit: { type: number, example: 10000.00 }
            CreditUsed: { type: number, example: 4500.00 }
            CreditLeft: { type: string, example: '5500.00' }
            isActive: { type: boolean }

    FxRateResponseV2:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            FXDate: { type: string }
            BaseCurrency: { type: string }
            SettlementCurrencies:
              type: array
              items:
                type: object
                properties:
                  SettlementCurrency: { type: string }
                  BillerTypes:
                    type: array
                    items:
                      type: object
                      properties:
                        BillerType: { type: string }
                        Country: { type: string }
                        FXRate: { type: string }

    FxRateResponseV21:
      allOf:
        - $ref: '#/components/schemas/PaginationEnvelope'
        - type: object
          properties:
            BaseCurrency: { type: string }
            FXDate: { type: string }
            SettlementCurrencies:
              type: array
              items:
                type: object
                properties:
                  SettlementCurrency: { type: string }
                  Country: { type: string }
                  BillerType: { type: string }
                  MidMarketRate: { type: string }
                  VendorRate: { type: string }
                  Bps: { type: integer }

    BillNotificationResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            NotificationDate: { type: string }
            Bills:
              type: array
              items: { $ref: '#/components/schemas/BillNotificationItem' }

    BillNotificationItem:
      type: object
      properties:
        EntityCustomerID: { type: string }
        BillerID: { type: string }
        SKU: { type: string }
        Inputs: { type: string }
        Amount: { type: string }
        BillDueDate: { type: string }

    EchoResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            CatalogVersion:
              type: string
              description: >
                Max catalog version across all billers — the canonical
                cache-invalidation signal. Cache the biller/SKU/IO catalogs
                locally and poll this (e.g. daily); only re-pull the full catalog
                when this value changes, rather than calling the catalog endpoints
                on every request.

    MobileCarrierLookupResponse:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            MobileNumber: { type: string }
            Country:
              type: object
              properties:
                CountryCode: { type: string }
                CountryName: { type: string }
            Biller:
              type: object
              properties:
                BillerID: { type: string }
                BillerName: { type: string }
            SKU:
              type: array
              items: { $ref: '#/components/schemas/SkuCatalogItem' }

    SkuCatalogItem:
      type: object
      description: >
        Field types are reported as the handler returns them; several numeric-ish
        fields may be strings or numbers depending on the source row.
      properties:
        SKU: { type: string }
        BillerID: { type: string }
        DaysToPost: {}
        CatalogVersion: { type: string }
        PartialPaymentAllowed: {}
        PastDuePaymentAllowed: {}
        ReceivingCurrency: { type: string }
        ReceivingAmount: { type: string }
        Amount: { type: string }
        MinAmount: {}
        MaxAmount: {}
        ResponseMessage: { type: string }
        ResponseCode: { type: string }
        BusinessDays: {}
        InquiryAvailable: {}
        ExcessPaymentAllowed: {}
        Type: { type: string }
        Description: { type: string }
        WalletCurrency: { type: string }
        WalletAmount: { type: string }
        MinWalletAmount: { type: string }
        MaxWalletAmount: { type: string }

    SkuCatalogItemV21:
      type: object
      description: >
        A single row in the v2.1 SKU catalog `Data` array. Heads-up for SDK
        generators: the catalog is built from raw DynamoDB attributes, so the
        execution-rule flags are **text strings** (`"0"`/`"1"`), not booleans or
        integers, and every monetary field is **string-encoded** (e.g. `"800.00"`,
        possibly empty `""` for open-amount SKUs). Do not coerce these to
        `number`/`boolean`. `IsBackendFeePercentage` is the one genuine boolean.
      properties:
        BillerID: { type: string }
        SKU: { type: string }
        CatalogVersion: { type: string }
        Type: { type: string }
        Description: { type: string }
        # ---- execution-rule flags: returned as "0"/"1" text strings ----
        PartialPaymentAllowed:
          type: string
          description: '`"0"` or `"1"` (text, not boolean).'
          example: '0'
        PastDuePaymentAllowed:
          type: string
          description: '`"0"` or `"1"` (text, not boolean).'
          example: '0'
        InquiryAvailable:
          type: string
          description: '`"0"` or `"1"` (text, not boolean).'
          example: '1'
        ExcessPaymentAllowed:
          type: string
          description: '`"0"` or `"1"` (text, not boolean).'
          example: '0'
        # ---- string-wrapped numerics ----
        DaysToPost:
          type: string
          description: String-encoded (may be empty).
          example: '2'
        BusinessDays:
          type: string
          description: String-encoded (may be empty).
          example: '1'
        Amount:
          type: string
          description: String-encoded amount; empty `""` for open-amount SKUs.
          example: '800.00'
        MinAmount: { type: string, example: '0.00' }
        MaxAmount: { type: string, example: '0.00' }
        ReceivingAmount: { type: string, example: '800.00' }
        ReceivingCurrency: { type: string, example: 'AED' }
        WalletAmount: { type: string, example: '800.00' }
        WalletCurrency: { type: string, example: 'USD' }
        MinWalletAmount: { type: string, example: '0.00' }
        MaxWalletAmount: { type: string, example: '0.00' }
        BackendFee:
          type: string
          description: String-encoded fee (enriched from the biller at read time).
          example: '0.50'
        # ---- genuinely-typed fields ----
        IsBackendFeePercentage:
          type: boolean
          description: True boolean (from the biller record), unlike the flags above.
        Tax: { type: string, example: '0.15' }
        Validity: { type: string, nullable: true, example: '30 days' }
        MoreInformation: { type: string, nullable: true }
        ResponseCode: { $ref: '#/components/schemas/ResponseCode' }
        ResponseMessage: { type: string }

    SkuCatalogResponseV21:
      allOf:
        - $ref: '#/components/schemas/PaginationEnvelope'
        - type: object
          properties:
            Data:
              type: array
              items: { $ref: '#/components/schemas/SkuCatalogItemV21' }

    PaginationEnvelope:
      allOf:
        - $ref: '#/components/schemas/ResponseEnvelope'
        - type: object
          properties:
            Page: { type: integer }
            PageSize: { type: integer }
            TotalPages: { type: integer }
            TotalRecords: { type: integer }

    PaginatedResponse:
      allOf:
        - $ref: '#/components/schemas/PaginationEnvelope'
        - type: object
          properties:
            Data:
              type: array
              items:
                type: object
                additionalProperties: true

    WebhookReplayResponse:
      type: object
      properties:
        ok: { type: boolean, example: true }
        matched:
          type: integer
          description: Deliveries matching the window + filters (before the cap).
        replayed:
          type: integer
          description: Deliveries actually re-enqueued (capped at max_events_per_request).
        errors: { type: integer }
        truncated:
          type: boolean
          description: True if matched exceeded the cap.
        by_type:
          type: object
          additionalProperties: { type: integer }
        max_events_per_request: { type: integer, example: 10000 }

    WebhookReplayError:
      type: object
      properties:
        ok: { type: boolean, example: false }
        error: { type: string }
        retry_after_seconds:
          type: integer
          description: Present on 429 responses.
