openapi: 3.0.3
info:
  title: SPQR Payments API
  version: 1.0.0
  description: |
    Create and retrieve PayIns and PayOuts through the SPQR Payments merchant API.

    Amounts are integer minor units. For example, `1250` MYR represents MYR 12.50.
    Every request to a merchant endpoint must use nested HMAC-SHA256 authentication.
  contact:
    name: SPQR Payments integration team
servers:
  - url: /
    description: The same host serving this contract
tags:
  - name: PayIns
    description: Incoming customer payments.
  - name: PayOuts
    description: Outgoing merchant disbursements.
paths:
  /payments:
    post:
      tags: [PayIns]
      summary: Create a PayIn
      operationId: createPayment
      description: |
        Creates a PayIn and submits it to the routing layer. A new request returns HTTP 201.
        An identical replay with the same `Idempotency-Key` returns HTTP 200 and the original operation.
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XCorrelationID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentRequest'
            example:
              merchant: acme_my
              amount: 12500
              currency: MYR
              external_id: order-8402
              country: MY
              payment_method: duitnowqr
              return_url: https://shop.example.com/orders/order-8402
              callback_url: https://api.shop.example.com/webhooks/spqr
              payer:
                customer_uid: customer-184
      responses:
        '201':
          description: PayIn created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatePaymentResponse'
        '200':
          description: Idempotent replay of the original request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatePaymentResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          description: Request body exceeds the configured limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
  /payments/{id}:
    get:
      tags: [PayIns]
      summary: Retrieve a PayIn
      operationId: getPayment
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XCorrelationID'
        - $ref: '#/components/parameters/OperationID'
        - $ref: '#/components/parameters/MerchantQuery'
      responses:
        '200':
          description: Current PayIn state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetPaymentResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /payouts:
    post:
      tags: [PayOuts]
      summary: Create a PayOut
      operationId: createPayout
      description: |
        Creates a PayOut. SPQR reserves the required operational balance before the PayOut
        enters processing. Recipient fields depend on the selected payment method.
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XCorrelationID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePayoutRequest'
            example:
              merchant: acme_my
              amount: 25000
              currency: MYR
              external_id: withdrawal-184
              country: MY
              payment_method: bank_transfer
              callback_url: https://api.shop.example.com/webhooks/spqr
              recipient:
                bank_code: MBBEMYKL
                account_name: Alex Tan
                account_number: '5144229981'
      responses:
        '201':
          description: PayOut created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatePayoutResponse'
        '200':
          description: Idempotent replay of the original request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatePayoutResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          description: The provider operational balance is insufficient.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorBody'
  /payouts/{id}:
    get:
      tags: [PayOuts]
      summary: Retrieve a PayOut
      operationId: getPayout
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XCorrelationID'
        - $ref: '#/components/parameters/OperationID'
        - $ref: '#/components/parameters/MerchantQuery'
      responses:
        '200':
          description: Current PayOut state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetPayoutResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  parameters:
    XApiKey:
      name: X-Api-Key
      in: header
      required: true
      description: Public integration key ID issued during onboarding.
      schema:
        type: string
        minLength: 1
        maxLength: 128
    XTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: Current Unix timestamp in seconds, represented as decimal digits.
      schema:
        type: string
        pattern: '^[0-9]+$'
    XSignature:
      name: X-Signature
      in: header
      required: true
      description: Lowercase 64-character nested HMAC-SHA256 signature.
      schema:
        type: string
        minLength: 64
        maxLength: 64
        pattern: '^[0-9a-f]{64}$'
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique key for this create attempt. Reuse it only to retry the identical request.
      schema:
        type: string
        minLength: 1
        maxLength: 128
    XCorrelationID:
      name: X-Correlation-ID
      in: header
      required: false
      description: Optional identifier propagated through downstream processing and ledger events.
      schema:
        type: string
    OperationID:
      name: id
      in: path
      required: true
      description: SPQR operation ID returned by the create endpoint.
      schema:
        type: string
        format: uuid
    MerchantQuery:
      name: merchant
      in: query
      required: true
      description: Merchant code that owns the operation.
      schema:
        type: string
        minLength: 1
  schemas:
    CreatePaymentRequest:
      type: object
      required: [merchant, amount, currency, external_id, country, payment_method]
      properties:
        merchant:
          type: string
          description: Merchant code issued by SPQR.
        provider_id:
          type: string
          format: uuid
          description: Optional legacy route lock. Omit it to use SPQR routing.
        amount:
          type: integer
          format: int64
          minimum: 1
          description: Amount in minor currency units.
        currency:
          type: string
          minLength: 3
          maxLength: 3
          pattern: '^[A-Za-z]{3}$'
          description: ISO 4217 currency code.
        external_id:
          type: string
          minLength: 1
          description: Unique order identifier in the merchant system.
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
          description: ISO 3166-1 alpha-2 country code.
        payment_method:
          type: string
          minLength: 1
          description: SPQR payment method code enabled for the merchant route.
        return_url:
          type: string
          format: uri
          description: Browser return URL after provider interaction. Falls back to merchant settings when omitted.
        callback_url:
          type: string
          format: uri
          description: HTTPS endpoint for status webhooks. Falls back to merchant settings when omitted.
        payer:
          type: object
          additionalProperties: true
          description: Provider-specific payer data.
    CreatePayoutRequest:
      type: object
      required: [merchant, amount, currency, external_id, country, payment_method, recipient]
      properties:
        merchant:
          type: string
          description: Merchant code issued by SPQR.
        provider_id:
          type: string
          format: uuid
          description: Optional legacy route lock. Omit it to use SPQR routing.
        amount:
          type: integer
          format: int64
          minimum: 1
          description: Amount in minor currency units.
        currency:
          type: string
          minLength: 3
          maxLength: 3
          pattern: '^[A-Za-z]{3}$'
          description: ISO 4217 currency code.
        external_id:
          type: string
          minLength: 1
          description: Unique disbursement identifier in the merchant system.
        country:
          type: string
          minLength: 2
          maxLength: 2
          pattern: '^[A-Za-z]{2}$'
          description: ISO 3166-1 alpha-2 country code.
        payment_method:
          type: string
          minLength: 1
          description: SPQR payout method code enabled for the merchant route.
        return_url:
          type: string
          format: uri
          description: Browser return URL after provider interaction. Falls back to merchant settings when omitted.
        callback_url:
          type: string
          format: uri
          description: HTTPS endpoint for status webhooks. Falls back to merchant settings when omitted.
        recipient:
          type: object
          additionalProperties: true
          description: Provider-specific recipient details. MYR bank transfer uses bank_code, account_name and account_number.
    ClientAction:
      type: object
      properties:
        type:
          type: string
        url:
          type: string
          format: uri
        data:
          type: object
          additionalProperties: true
    CreatePaymentResponse:
      type: object
      required: [payment_id, status, created_at]
      properties:
        payment_id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/Status'
        created_at:
          type: string
          format: date-time
        provider_id:
          type: string
          format: uuid
        provider_code:
          type: string
        router_transaction_id:
          type: string
          format: uuid
        route_id:
          type: string
          format: uuid
        client_action:
          $ref: '#/components/schemas/ClientAction'
    CreatePayoutResponse:
      type: object
      required: [payout_id, status, created_at]
      properties:
        payout_id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/Status'
        created_at:
          type: string
          format: date-time
        provider_id:
          type: string
          format: uuid
        provider_code:
          type: string
        router_transaction_id:
          type: string
          format: uuid
        route_id:
          type: string
          format: uuid
    GetPaymentResponse:
      type: object
      required: [payment_id, merchant, status, moderation_required, amount, currency, external_id, created_at, status_times]
      properties:
        payment_id:
          type: string
          format: uuid
        merchant:
          type: string
        provider_id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/Status'
        resolution:
          $ref: '#/components/schemas/Resolution'
        moderation_required:
          type: boolean
        amount:
          type: integer
          format: int64
        currency:
          type: string
        external_id:
          type: string
        created_at:
          type: string
          format: date-time
        status_times:
          $ref: '#/components/schemas/StatusTimes'
    GetPayoutResponse:
      type: object
      required: [payout_id, merchant, status, moderation_required, amount, currency, external_id, recipient, created_at, status_times]
      properties:
        payout_id:
          type: string
          format: uuid
        merchant:
          type: string
        provider_id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/Status'
        resolution:
          $ref: '#/components/schemas/Resolution'
        moderation_required:
          type: boolean
        amount:
          type: integer
          format: int64
        currency:
          type: string
        external_id:
          type: string
        recipient:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        status_times:
          $ref: '#/components/schemas/StatusTimes'
        reservation_id:
          type: string
          format: uuid
    Status:
      type: string
      enum: [NEW, PENDING, SUCCEEDED, FAILED_INTERNAL, FAILED_PROVIDER, CANCELLED]
    Resolution:
      type: string
      enum:
        - ok
        - no_route_available
        - provider_error_retryable
        - provider_error_terminal
        - invalid_request
        - limit_violation
        - attempt_limit_reached
        - provider_balance_insufficient
        - cancelled
        - expired
        - internal_error
    StatusTimes:
      type: object
      description: First recorded entry time for each lifecycle state.
      properties:
        new_at:
          type: string
          format: date-time
        pending_at:
          type: string
          format: date-time
        succeeded_at:
          type: string
          format: date-time
        failed_at:
          type: string
          format: date-time
        cancelled_at:
          type: string
          format: date-time
    ErrorBody:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
            message:
              type: string
            request_id:
              type: string
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  message:
                    type: string
  responses:
    BadRequest:
      description: Request validation failed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Unauthorized:
      description: HMAC credentials, timestamp or merchant scope are invalid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Forbidden:
      description: The merchant or requested operation type is disabled.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    NotFound:
      description: The merchant or operation was not found in the authenticated scope.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Conflict:
      description: An idempotency key was reused with a different request or the operation state conflicts.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
