openapi: 3.1.0
info:
  title: iPayMoney Merchant API
  version: 1.0.0
  description: >-
    v1 merchant contracts verified against the Rails routes, controllers and
    services. Partner contracts and non-operational v2 routes are not part of
    this public specification. Payment links must use private random
    references.
servers:
  - url: https://i-pay.money/api/v1
    description: Sandbox or Live depending on the headers and the key used
tags:
  - name: Payments
    description: Creation and tracking of merchant transactions
  - name: Payment links
    description: Hosted payment links
paths:
  /payments:
    get:
      tags: [Payments]
      summary: List transactions
      description: >-
        Server-side pagination: the number of items per page is set with the
        `items` parameter (25 by default). The response remains a raw JSON
        array, without any structured pagination metadata: no `meta` envelope,
        no `Link` or `Total-Count` header. The page parameter is not
        documented by this contract, and the API returns neither a total nor a
        next-page link that would let the client build it.
      x-verified: >-
        Internal verification note (not intended for the public
        documentation). Verified on 2026-09-16 in `Api::V1::PaymentsController#index`
        (app/controllers/api/v1/payments_controller.rb:4-16): no call to
        `pagy_headers_merge` or `pagy_metadata` anywhere in the Rails
        repository (exhaustive search), and
        `app/views/api/v1/payments/index.json.jbuilder:1` renders
        `json.array! @payments` directly, without a `meta` envelope or a `Link` /
        `Total-Count` header. The Pagy `headers` and `metadata` extras are loaded
        in `config/initializers/pagy.rb` but are not wired to any
        route in this repository: the capability exists in the gem, not in this
        controller. Pagy uses its default page parameter name
        (`page`, not overridden in the initializer).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ContentType'
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
        - name: query
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema:
            type: string
            enum: [initiated, pending, succeeded, failed, refunded]
        - name: created_from
          in: query
          schema: { type: string, format: date }
        - name: created_to
          in: query
          schema: { type: string, format: date }
        - name: items
          in: query
          schema: { type: integer, default: 25, minimum: 1 }
      responses:
        '200':
          description: >-
            List of transactions (raw JSON array, without a pagination
            envelope). The `status` and `payment_method` values are
            formatted for display (e.g. `Succeeded`, `Airtel Ne`) and not
            normalized: do not compare them with the status values used
            by the other operations.
          x-verified: >-
            Internal verification note (not intended for the public
            documentation). Formatting rendered by `payment.displayed_status` and
            `payment.displayed_payment_method` — see
            app/views/api/v1/payments/_payment.json.jbuilder:3,6.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PaymentListItem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
    post:
      tags: [Payments]
      summary: Create a transaction
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentRequest' }
            examples:
              sandboxMobile:
                summary: Successful Sandbox mobile transaction
                value:
                  amount: 1000
                  country: NE
                  currency: XOF
                  msisdn: '40410000000'
                  transaction_id: ORDER-SANDBOX-001
                  customer_name: Client Test
      responses:
        '200':
          description: Transaction registered. The final status must be checked separately.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentCreateResponse' }
        '400':
          description: >-
            Invalid header or authentication parameter, or invalid request
            body (missing field or value outside the constraints).
          x-verified: >-
            Internal verification note (not intended for the public
            documentation). Detected by `ApiAuthService#authenticate`
            (app/services/api_auth_service.rb:20-26) or
            `Live::PaymentService#request_payment`
            (app/services/live/payment_service.rb). Exact messages verified
            on 2026-09-16.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingHeaderParams:
                  summary: Missing authentication header
                  value: { message: 'Bad Request: Missing params' }
                notAllowedEnvironment:
                  summary: Ipay-Target-Environment not allowed
                  value: { message: 'Bad Request: Not Allowed Environment' }
                notAllowedPaymentType:
                  summary: Ipay-Payment-Type not allowed
                  value: { message: 'Bad Request: Not Allowed Payment Type' }
                missingAuthenticationKey:
                  summary: Authorization Bearer missing or malformed
                  value: { message: 'Bad Request: Missing Authentication Key' }
                missingBodyParams:
                  summary: Required field missing from the body (amount, country, currency, msisdn, transaction_id or customer_name)
                  value: { message: 'Bad Request: Missing Body Params' }
                countryNotAllowed:
                  summary: country other than NE or BJ
                  value: { message: 'Bad Request: Country Not Allowed' }
                currencyNotAllowed:
                  summary: currency other than XOF
                  value: { message: 'Bad Request: Currency Not Allowed' }
                amountNotValid:
                  summary: amount below the applicable minimum (25 XOF in Live, 50 XOF in Sandbox)
                  value: { message: 'Bad Request: Amount Not Valid' }
                incorrectMsisdn:
                  summary: msisdn in an invalid format for the method
                  value: { message: 'Bad Request: Incorrect MSISDN' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
        '422':
          description: >-
            External reference (`transaction_id`) already used for this account:
            duplicate detection applied at the application level, not guaranteed
            under concurrent calls — the API does not expose any idempotency key.
            This code also covers a payment that is invalid for another
            validation reason.
          x-verified: >-
            Internal verification note (not intended for the public
            documentation). See `Live::PaymentService#error_response`,
            app/services/live/payment_service.rb:994-1005.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                duplicateExternalReference:
                  summary: transaction_id already used for this account
                  value: { status: failed, message: External Reference Not Valid }
  /payments/{reference}:
    get:
      tags: [Payments]
      summary: Retrieve a transaction
      description: Accepts the iPayMoney internal reference or the merchant's external reference.
      security:
        - bearerAuth: []
      parameters:
        - name: reference
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/ContentType'
        - $ref: '#/components/parameters/TargetEnvironment'
        - $ref: '#/components/parameters/PaymentType'
      responses:
        '200':
          description: Transaction found for the authenticated account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: Transaction not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '406': { $ref: '#/components/responses/NotAcceptable' }
  /external_payments:
    post:
      tags: [Payment links]
      summary: Create a hosted payment link
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TargetEnvironment'
        - name: Ipay-Payment-Type
          in: header
          required: true
          schema: { type: string, const: external_payment }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExternalPaymentRequest' }
            examples:
              orderLink:
                summary: Payment link for an order
                value:
                  title: 'Order #1001'
                  description: 'Transaction for order #1001'
                  amount: 15000
                  on_success_redirection_url: https://your-site.com/orders/1001/success
                  on_failed_redirection_url: https://your-site.com/orders/1001/failure
      responses:
        '200':
          description: Link created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExternalPaymentCreateResponse' }
        '404':
          description: Account not found or inactive
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: Invalid parameters
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationError' }
  /external_payments/{reference}:
    get:
      tags: [Payment links]
      summary: Retrieve a payment link
      description: >-
        This route is currently accessible without a key and can return
        customer data. Use a randomly generated private reference,
        never a sequential order identifier.
      security: []
      parameters:
        - name: reference
          in: path
          required: true
          description: High-entropy private reference, not published on the client side.
          schema: { type: string, minLength: 32 }
      responses:
        '200':
          description: State of the link or of the associated payment
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ExternalPaymentStatus'
                  - $ref: '#/components/schemas/ExternalPaymentWaiting'
                  - $ref: '#/components/schemas/ExternalPaymentExpired'
        '404':
          description: Link not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Secret key of the Sandbox or Live account
  parameters:
    ContentType:
      name: Content-Type
      in: header
      required: true
      description: >-
        Required by the API authentication, including on GET requests,
        even though they have no body.
      schema: { type: string, const: application/json }
    TargetEnvironment:
      name: Ipay-Target-Environment
      in: header
      required: true
      schema:
        type: string
        enum: [sandbox, live]
    PaymentType:
      name: Ipay-Payment-Type
      in: header
      required: true
      description: >-
        Payment method. `POST /payments` only supports
        `mobile` (mobile money). Other payment methods (bank
        card and other channels) are not used here: they are
        available through payment links (`POST /external_payments`).
        Full list: [payment methods](/en/guides/payouts-and-fees#fees-by-payment-method).
      x-verified: >-
        Internal verification note (not intended for the public
        documentation). Scope decision: only the `mobile` value is
        documented for `POST /payments`; the other values handled by
        `Live::PaymentService` are still accepted by the API but belong to the
        "payment link" flow. `external_payment` is reserved for
        `POST /external_payments` (dedicated parameter of that operation).
      schema:
        type: string
        const: mobile
        example: mobile
  schemas:
    PaymentRequest:
      type: object
      description: >-
        Body required to create a mobile money payment (`Ipay-Payment-Type:
        mobile`). The minimum amount is 25 XOF (50 XOF in Sandbox). Below
        that, the API responds 400 `Bad Request: Amount Not Valid`. For
        other payment methods, use payment links.
      x-verified: >-
        Internal verification note (not intended for the public
        documentation). Body required by `Live::PaymentService#request_payment`
        (app/services/live/payment_service.rb). `mobile` threshold ≥ 25 verified on
        2026-09-16 (`verify_mobile_amount_value`). The thresholds of the other types
        are no longer documented here (scope reduced to `mobile`).
      required: [amount, country, currency, msisdn, transaction_id, customer_name]
      properties:
        amount:
          type: integer
          minimum: 25
          example: 1000
          description: >-
            Amount to debit, in XOF (integer). Minimum: 25 XOF in Live,
            50 XOF in Sandbox.
        country: { type: string, enum: [NE, BJ], example: NE }
        currency: { type: string, const: XOF }
        msisdn: { type: string, example: '40410000000' }
        transaction_id: { type: string, description: Unique reference within the account }
        customer_name: { type: string }
    PaymentCreateResponse:
      type: object
      required: [status, reference, public_reference]
      properties:
        status: { type: string, example: succeeded }
        reference: { type: string }
        public_reference: { type: string }
        meta_data: { type: object, additionalProperties: true }
    PaymentStatus:
      type: object
      required: [public_reference, external_reference, reference, status, msisdn, amount]
      properties:
        public_reference: { type: string }
        external_reference: { type: string }
        reference: { type: string }
        status:
          type: string
          enum: [initiated, pending, succeeded, failed, refunded]
        msisdn: { type: string }
        amount: { type: integer }
        validated_at: { type: [string, 'null'], format: date-time }
    PaymentListItem:
      type: object
      properties:
        id: { type: integer }
        reference: { type: string }
        status: { type: string, example: Succeeded }
        amount: { type: integer }
        fees: { type: integer }
        payment_method:
          type: string
          example: Airtel Ne
          description: Payment method used by the customer — see the list of [payment methods](/en/guides/payouts-and-fees#fees-by-payment-method).
        net_amount: { type: integer }
        platform: { type: string }
        device: { type: string }
        failure_reason: { type: [string, 'null'] }
        country: { type: string }
        currency: { type: string }
        external_reference: { type: string }
        msisdn: { type: string }
        customer_name: { type: string }
        created_at: { type: string, format: date-time }
        validated_at: { type: [string, 'null'], format: date-time }
    ExternalPaymentRequest:
      type: object
      required: [title]
      properties:
        title: { type: string }
        description: { type: string }
        amount: { type: [integer, 'null'], minimum: 50 }
        reference:
          type: string
          minLength: 32
          description: Omit for automatic generation, or provide a private random value of at least 128 bits.
        shouldExpire:
          type: boolean
          const: true
          default: true
          description: The current v1 always creates an expiring link.
        on_success_redirection_url: { type: string, format: uri, pattern: '^https://' }
        on_failed_redirection_url: { type: string, format: uri, pattern: '^https://' }
    ExternalPaymentCreateResponse:
      type: object
      properties:
        title: { type: string }
        amount: { type: [integer, 'null'] }
        reference: { type: string }
        status: { type: string, example: initiated }
        should_expire: { type: boolean }
        page_url: { type: string, format: uri }
    ExternalPaymentStatus:
      type: object
      description: >-
        Shape returned when a payment has been carried through to completion for this link.
        `status` can then only be `succeeded` or `failed`: on this
        operation, never `initiated`, `pending` or `refunded`. Edge case: a
        payment refunded afterwards is no longer attached to the link, and
        the operation then returns the shape of a link still waiting
        (`status: initiated`) instead of exposing the refund — do not
        use this operation as a reliable refund indicator.
      x-verified: >-
        Internal verification note (not intended for the public
        documentation). Verified on 2026-09-16 in
        `Api::V1::ExternalPaymentsController#show`
        (app/controllers/api/v1/external_payments_controller.rb:41-59, 63-92):
        this schema is only rendered for
        `most_recent_successful || most_recent_failed`, two scopes filtered
        on `payments.status = 2` and `payments.status = 3` respectively
        (lines 68-73), to be compared with the `Payment.status` enum
        (app/models/payment.rb:119 — initiated:0, pending:1, succeeded:2,
        failed:3, refunded:4). The existing enum was already correct,
        confirmed rather than fixed. If a `succeeded` payment is later
        marked `refunded` in the database, it no longer matches either of the two
        SQL scopes and this route then renders `ExternalPaymentWaiting`.
      properties:
        reference: { type: string }
        amount: { type: integer }
        country: { type: string }
        currency: { type: string }
        transaction_id: { type: string }
        msisdn: { type: string }
        customer_name: { type: string }
        status: { type: string, enum: [succeeded, failed] }
        created_at: { type: string, format: date-time }
    ExternalPaymentWaiting:
      type: object
      properties:
        message: { type: string }
        status: { type: string, const: initiated }
        reference: { type: string }
        page_url: { type: string, format: uri }
    ExternalPaymentExpired:
      type: object
      properties:
        message: { type: string }
        has_expire: { type: boolean, const: true }
        status: { type: string, const: cancelled }
        reference: { type: string }
    Error:
      type: object
      properties:
        message: { type: string }
        status:
          type: string
          description: >-
            Only present on some application failures, e.g. a duplicate
            payment on `POST /payments`, where `status` is always `failed`.
            Absent from the other errors of this contract (e.g. 404
            `Payment Not Found`, header/authentication errors).
          x-verified: >-
            Internal verification note (not intended for the public
            documentation). See `Live::PaymentService#error_response`
            (app/services/live/payment_service.rb:994-1005).
          example: failed
    ValidationError:
      type: object
      properties:
        errors:
          type: array
          items: { type: string }
  responses:
    BadRequest:
      description: >-
        Invalid header or parameter, detected before any business logic.
      x-verified: >-
        Internal verification note (not intended for the public
        documentation). Detected by `ApiAuthService#authenticate`
        (app/services/api_auth_service.rb:20-26) — exact messages verified
        on 2026-09-16.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missingHeaderParams:
              summary: Missing authentication header
              value: { message: 'Bad Request: Missing params' }
            notAllowedEnvironment:
              summary: Ipay-Target-Environment not allowed
              value: { message: 'Bad Request: Not Allowed Environment' }
            notAllowedPaymentType:
              summary: Ipay-Payment-Type not allowed
              value: { message: 'Bad Request: Not Allowed Payment Type' }
            missingAuthenticationKey:
              summary: Authorization Bearer missing or malformed
              value: { message: 'Bad Request: Missing Authentication Key' }
    Unauthorized:
      description: Invalid key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Account or environment not available
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotAcceptable:
      description: Content-Type other than application/json
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
