openapi: 3.0.3
info:
  title: PSP Gateway Transfer API
  version: 1.0.0
  description: |
    Transfer service surface for merchant disbursement write flows. This service
    only accepts transfer account secrets and rejects invoice/query/webhook/proof
    traffic.
    A missing X-API-ID header returns 400 INVALID_REQUEST. A missing X-API-Key
    header or invalid credentials return 401 UNAUTHORIZED. A credential store
    failure returns 500 INTERNAL_ERROR before the request handler runs.
servers:
  - url: https://transfer-api.psp.example.com
    description: Example host; obtain the actual Transfer host from PSP
tags:
  - name: Transfers
security:
  - AccountSecretEd25519: []
paths:
  /api/v1/payments/transfers:
    post:
      tags: [Transfers]
      summary: Create a transfer intent
      description: |
        Requires a transfer account secret and `transfers:write` scope.

        Send the idempotency key in `X-Idempotency-Key` or `Idempotency-Key`;
        at least one is required. When both are sent they must be equal,
        otherwise the request is rejected with 400 INVALID_REQUEST. The Ed25519
        signature covers `X-Idempotency-Key` when it is sent, otherwise
        `Idempotency-Key`.

        The same header key is also the transfer's business idempotency key:
        reusing it for a different transfer request is rejected with
        409 CONFLICT. The request body has no separate idempotency field.
      parameters:
        - $ref: '#/components/parameters/APIKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Signature'
        - $ref: '#/components/parameters/XIdempotencyKey'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransferRequest'
      responses:
        '500':
          $ref: '#/components/responses/AuthenticationDependencyFailure'
        '201':
          description: Transfer intent created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/WrongServiceOrCredential'
        '403':
          $ref: '#/components/responses/PermissionDenied'
        '404':
          description: ACCOUNT_FEE_NOT_FOUND. No canonical transfer pricing policy matches this account, rail, currency and operation. The transfer is not created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIError'
        '409':
          $ref: '#/components/responses/Conflict'
  /api/v1/payments/transfers/{intent_id}/cancel:
    post:
      tags: [Transfers]
      summary: Cancel a transfer intent
      description: |
        Send the idempotency key in `X-Idempotency-Key` or `Idempotency-Key`;
        at least one is required. When both are sent they must be equal,
        otherwise the request is rejected with 400 INVALID_REQUEST. The Ed25519
        signature covers `X-Idempotency-Key` when it is sent, otherwise
        `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/IntentID'
        - $ref: '#/components/parameters/APIKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Signature'
        - $ref: '#/components/parameters/XIdempotencyKey'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelTransferRequest'
      responses:
        '500':
          $ref: '#/components/responses/AuthenticationDependencyFailure'
        '200':
          description: Transfer intent canceled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelTransferResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/WrongServiceOrCredential'
        '403':
          $ref: '#/components/responses/PermissionDenied'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /api/v1/orders/{order_id}/sync:
    post:
      tags: [Transfers]
      summary: Request a transfer order sync
      description: |
        Requires a transfer account secret and `transfers:write` scope. A
        non-terminal transfer is never advanced by this request: the gateway
        delivers one workflow command derived from the idempotency key and
        returns `action=update_transfer_workflow` with `synced=false` once the
        Transfer workflow accepts it. The workflow observes the provider and
        alone decides the terminal state and funds; read the outcome from
        webhooks or the query service. Retrying with the same idempotency key
        maps to the same workflow command. A transfer that is not yet submitted
        or whose submission is unconfirmed (state `submission_unknown`, even when
        a provider ID was saved) returns `action=none`. A completed
        transfer only refreshes its channel fee (`action=query_channel_status`).
        If the transfer's workflow run has failed, the request answers 503
        until operations recover the workflow; merchant retries, with the same
        or a new idempotency key, never restart it.

        Send the idempotency key in `X-Idempotency-Key` or `Idempotency-Key`;
        at least one is required. When both are sent they must be equal,
        otherwise the request is rejected with 400 INVALID_REQUEST. The Ed25519
        signature covers `X-Idempotency-Key` when it is sent, otherwise
        `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/OrderID'
        - $ref: '#/components/parameters/APIKey'
        - $ref: '#/components/parameters/Timestamp'
        - $ref: '#/components/parameters/Signature'
        - $ref: '#/components/parameters/XIdempotencyKey'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '500':
          $ref: '#/components/responses/AuthenticationDependencyFailure'
        '200':
          description: Sync request handled; see `action` for what was done.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncOrderResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/WrongServiceOrCredential'
        '403':
          $ref: '#/components/responses/PermissionDenied'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
components:
  securitySchemes:
    AccountSecretEd25519:
      type: apiKey
      in: header
      name: X-API-ID
      description: Account secret Ed25519 authentication for transfer credentials; PSP stores only the public key.
  parameters:
    APIKey:
      name: X-API-Key
      in: header
      required: true
      schema: { type: string }
    Timestamp:
      name: X-Timestamp
      in: header
      required: true
      schema: { type: string }
    Signature:
      name: X-Signature
      in: header
      required: true
      schema: { type: string }
    XIdempotencyKey:
      name: X-Idempotency-Key
      in: header
      required: false
      description: |
        Idempotency key. Send it in this header or in Idempotency-Key; at least
        one of the two is required. When this header is sent, the Ed25519
        signature covers its value. If both headers are sent they must be
        equal, otherwise the request is rejected with 400 INVALID_REQUEST.
      schema:
        $ref: '#/components/schemas/IdempotencyKey'
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Idempotency key, alternative to X-Idempotency-Key; at least one of the
        two is required. The Ed25519 signature covers this header only when
        X-Idempotency-Key is not sent. If both headers are sent they must be
        equal, otherwise the request is rejected with 400 INVALID_REQUEST.
      schema:
        $ref: '#/components/schemas/IdempotencyKey'
    IntentID:
      name: intent_id
      in: path
      required: true
      schema: { type: string }
    OrderID:
      name: order_id
      in: path
      required: true
      schema: { type: string, format: uuid }
  schemas:
    IdempotencyKey:
      type: string
      minLength: 1
      description: |
        Merchant-chosen idempotency key, shared by both idempotency headers.
        Surrounding whitespace is ignored and a blank value counts as missing.
        The gateway uses it for two things only: the Ed25519 signature covers
        it, and it deduplicates requests within the merchant account and
        endpoint path (a retry with the same key and body gets the first
        response once the gateway has stored it, for example a success or 409
        response for 24 hours; the same key with a different body is rejected
        with 422 VALIDATION_ERROR). Whether an operation also uses it
        as its business idempotency key is stated in the operation description.
    CreateTransferRequest:
      type: object
      required: [payment_method, rail_variant, amount, currency]
      properties:
        payment_method:
          type: string
          enum: [pix, spei, upi, america_ach, paypal, chime, zelle, cash_app, venmo, emt, apple_pay]
        rail_variant:
          type: string
          description: Canonical rail variant paired with payment_method.
          example: pix_key
        amount:
          type: integer
          format: int64
          minimum: 1
        currency:
          type: string
          minLength: 3
          maxLength: 3
        beneficiary:
          $ref: '#/components/schemas/Beneficiary'
        instruction:
          type: object
          additionalProperties: true
          description: >
            PIX QR may omit beneficiary; other transfer modes require beneficiary name and account.
            PIX and SPEI consume `instruction.tax_id`. It must be a string; a number or object
            returns INVALID_REQUEST. UPI, ACH, and hosted methods ignore this field and do not
            persist it as beneficiary.tax_id.
            When transfer_beneficiary_validation_mode is optional or required and the value is
            non-empty, it must match the account market format (Brazil CPF, numeric or
            alphanumeric CNPJ, or Mexico RFC) and must match `beneficiary.tax_id` when that
            field is also set. Off mode does not send the value upstream. PIX keeps an
            instruction-only tax_id off beneficiary.tax_id so older Gateway versions can replay
            the same request. SPEI likewise keeps an explicit RFC and an instruction-only alias
            off beneficiary.tax_id. Off mode does not backfill the channel RFC from
            beneficiary.tax_id or instruction.tax_id; an explicit instruction.beneficiary_rfc
            is still submitted unchanged. Optional and required modes persist the RFC
            decision for the channel and worker.
            SPEI `beneficiary_rfc` must be a string. When transfer_beneficiary_validation_mode is
            optional or required and this field is non-empty (including when beneficiary.tax_id is
            omitted), it must be a Mexican RFC (12 or 13 characters, month 01–12, day 01–31);
            otherwise the request returns INVALID_REQUEST.
        description:
          type: string
          maxLength: 500
        merchant_order_id:
          type: string
          maxLength: 100
        webhook_url:
          type: string
          format: uri
          maxLength: 500
        metadata:
          type: object
          additionalProperties: true
    Beneficiary:
      type: object
      properties:
        name: { type: string, maxLength: 200 }
        tax_id:
          type: string
          maxLength: 50
          description: >
            收款人证件号。账户 transfer_beneficiary_validation_mode 为 optional 或 required
            且提交了本字段时，须符合账户市场格式：巴西 CPF（11 位数字含校验位）或 CNPJ
            （14 位，数字或字母数字，含官方校验位，例如 00.000.000/E08G-12）；墨西哥 RFC
            （12 或 13 位，日期段月份 01–12、日期 01–31）。格式错误返回 INVALID_REQUEST。
            required 档未提交亦返回 INVALID_REQUEST。
        email: { type: string, format: email, maxLength: 200 }
        phone: { type: string, maxLength: 50 }
        account: { type: string, maxLength: 200 }
        bank_code: { type: string, maxLength: 20 }
        branch_code: { type: string, maxLength: 20 }
    PaymentClassification:
      type: object
      additionalProperties: false
      required: [classification_status, payment_category, payment_method, rail_variant]
      properties:
        classification_status: { type: string, enum: [resolved] }
        payment_category: { type: string }
        payment_method: { type: string }
        rail_variant: { type: string }
    TransferResponse:
      type: object
      required: [id, transfer_number, payment_method, classification, amount, currency, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        transfer_number: { type: string }
        payment_method: { type: string }
        classification:
          $ref: '#/components/schemas/PaymentClassification'
        amount: { type: integer, format: int64 }
        currency: { type: string }
        transferred_amount: { type: integer, format: int64 }
        returned_amount: { type: integer, format: int64 }
        status: { type: string, enum: [pending, processing, completed, failed, returned, canceled] }
        state: { type: string }
        description: { type: string }
        merchant_order_id: { type: string }
        provider_transaction_id: { type: string }
        beneficiary:
          allOf:
            - $ref: '#/components/schemas/Beneficiary'
          description: >-
            The beneficiary the merchant submitted and PSP sent upstream. Echoed back
            unchanged; a provider callback never overwrites it.
        actual_beneficiary:
          allOf:
            - $ref: '#/components/schemas/Beneficiary'
          description: >-
            The beneficiary the provider reported back after settling the transfer.
            Absent until a callback carries one, and absent for good on providers that
            do not report it. Compare it against `beneficiary` to tell whether the money
            reached the requested account.
        failure_reason: { type: string }
        failure_code: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        submitted_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        failed_at: { type: string, format: date-time }
        returned_at: { type: string, format: date-time }
        authorization_url:
          type: string
          format: uri
          description: Short-lived payer authorization URL, available after asynchronous channel submission.
        authorization_url_expires_at: { type: string, format: date-time }
        metadata:
          type: object
          additionalProperties: true
    CancelTransferRequest:
      type: object
      properties:
        reason: { type: string, maxLength: 500 }
    CancelTransferResponse:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [canceled] }
    SyncOrderResponse:
      type: object
      required: [order_id, order_type, previous_state, current_state, synced, synced_at]
      properties:
        order_id: { type: string, format: uuid }
        order_type: { type: string, enum: [transfer_intent, transfer_refund] }
        action:
          type: string
          enum: [none, query_channel_status, update_transfer_workflow]
          description: |
            `update_transfer_workflow` means the Transfer workflow accepted the
            sync command; the gateway did not query the provider or write state.
        previous_state: { type: string }
        current_state:
          type: string
          description: Local state when the response was built; workflow results arrive asynchronously.
        synced:
          type: boolean
          description: Always false for `update_transfer_workflow`.
        synced_at: { type: string, format: date-time }
        message: { type: string }
    APIError:
      type: object
      required: [code, message]
      properties:
        code: { type: string }
        message: { type: string }
  responses:
    AuthenticationDependencyFailure:
      description: Credential lookup failed (INTERNAL_ERROR). The request handler was not executed.
    InvalidRequest:
      description: The request body or path parameters are invalid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
    WrongServiceOrCredential:
      description: The credential kind or host does not match the transfer service.
    PermissionDenied:
      description: The account secret lacks the required transfer scope.
    NotFound:
      description: The transfer does not exist for this account.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
    Conflict:
      description: The idempotency key or transfer state conflicts with the request.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
    ServiceUnavailable:
      description: The Transfer workflow or provider cannot accept the request now; retry with the same idempotency key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/APIError'
