openapi: 3.1.0
# Target build specification. It is not a declaration that these routes are publicly available.
info:
  title: MainPay Orders API
  version: "2026-07-01"
  description: >
    Non-custodial stablecoin Orders API. Create an order (invoice) for an exact stablecoin amount
    routed to a merchant-controlled address; the customer pays on-chain; MainPay deterministically
    matches the payment and emits a signed webhook. No custody: there is no capture, void, refund,
    hold, payout, or split. Money values are decimal strings. See docs/api for narrative reference.
servers:
  - url: https://api.mainpay.com/v1
    description: >
      Public, versioned developer API. The dashboard's own internal routes live under /api (session
      auth) and are intentionally not described here.
security:
  - ApiKeyAuth: []

tags:
  - name: Orders
  - name: Wallets

paths:
  /invoices:
    post:
      tags: [Orders]
      summary: Create an order
      operationId: createOrder
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderCreateParams' }
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimitError' }
        '503': { $ref: '#/components/responses/ServiceUnavailableError' }
    get:
      tags: [Orders]
      summary: List orders
      operationId: listOrders
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/OrderStatus' }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: starting_after
          in: query
          description: Cursor — a previous order public_id.
          schema: { type: string }
      responses:
        '200':
          description: A page of orders
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Order' }
        '429': { $ref: '#/components/responses/RateLimitError' }

  /invoices/{public_id}:
    parameters:
      - $ref: '#/components/parameters/PublicId'
    get:
      tags: [Orders]
      summary: Retrieve an order
      operationId: getOrder
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimitError' }

  /invoices/{public_id}/cancel:
    parameters:
      - $ref: '#/components/parameters/PublicId'
    post:
      tags: [Orders]
      summary: Cancel an order
      operationId: cancelOrder
      description: Only pending or underpaid orders may be cancelled.
      responses:
        '200':
          description: Cancelled order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimitError' }

  /test/invoices/{public_id}/pay:
    parameters:
      - $ref: '#/components/parameters/PublicId'
    post:
      tags: [Orders]
      summary: Simulate a test invoice payment
      operationId: simulateTestPayment
      description: Test keys and test invoices only. No chain settlement occurs.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TestPaymentSimulation' }
      responses:
        '200':
          description: Updated test order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimitError' }

  /checkout/{public_id}:
    parameters:
      - $ref: '#/components/parameters/PublicId'
    get:
      tags: [Orders]
      summary: Retrieve checkout data (public, no auth)
      operationId: getCheckout
      security: []
      responses:
        '200':
          description: Order (payer-safe view)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Checkout' }
        '404': { $ref: '#/components/responses/Error' }

  /checkout/{public_id}/events:
    parameters:
      - $ref: '#/components/parameters/PublicId'
    post:
      tags: [Orders]
      summary: Record an anonymous checkout funnel step
      operationId: recordCheckoutEvent
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutEventCreate' }
      responses:
        '202':
          description: Event accepted (duplicate steps are safely de-duplicated)
          content:
            application/json:
              schema:
                type: object
                required: [accepted, duplicate]
                properties:
                  accepted: { type: boolean, const: true }
                  duplicate: { type: boolean }
        '404': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimitError' }

  /wallets:
    get:
      tags: [Wallets]
      summary: List receiving accounts
      operationId: listWallets
      responses:
        '200':
          description: Wallets
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Wallet' }
        '429': { $ref: '#/components/responses/RateLimitError' }

webhooks:
  order-event:
    post:
      summary: Order state-change event
      description: >
        MainPay POSTs a signed event to the merchant webhook URL. Header X-MainPay-Signature is
        't=<unix>,v1=<hex hmac_sha256(secret, "{t}.{raw_body}")>'. At-least-once; dedupe on id.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Event' }
      responses:
        '200':
          description: Acknowledged (any 2xx). Non-2xx is retried with backoff.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Workspace key: mp_live_… (live) or mp_test_… (sandbox)."

  parameters:
    PublicId:
      name: public_id
      in: path
      required: true
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Caller-generated unique string; replays return the original resource.
      schema: { type: string, maxLength: 255 }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: Field validation failed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationErrorBody' }
    RateLimitError:
      description: API-key request limit exceeded
      headers:
        Retry-After:
          description: Seconds until the caller should retry.
          schema: { type: integer, minimum: 1 }
        RateLimit-Limit:
          description: Per-minute request limit for this API key.
          schema: { type: integer, minimum: 1 }
        RateLimit-Remaining:
          description: Requests remaining in the current minute window.
          schema: { type: integer, minimum: 0 }
        RateLimit-Reset:
          description: Seconds until the current minute window resets.
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServiceUnavailableError:
      description: New invoice creation is paused during a rollout or incident
      headers:
        Retry-After:
          description: Seconds before checking whether creation has resumed.
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Money:
      type: string
      description: Decimal string. Never a float.
      pattern: '^(0|[1-9][0-9]*)(\.[0-9]+)?$'
      examples: ["500.000123"]
    Asset:
      type: string
      enum: [USDC, USDT]
    Network:
      type: string
      enum: [arbitrum, base, ethereum, hyperevm, optimism, solana, tron]
    OrderStatus:
      type: string
      enum: [pending, paid, underpaid, overpaid, expired, cancelled]
    SettlementBasis:
      type: string
      enum: [open, exact, within_tolerance, accepted_shortfall, underpaid, overpaid]

    OrderCreateParams:
      type: object
      required: [fiat_amount, asset, network]
      properties:
        reference: { type: string, maxLength: 80, description: Omit to auto-number INV-YYYY-NNNN. }
        customer_id: { type: string, description: A saved customer; else provide customer_name. }
        customer_name: { type: string, maxLength: 160 }
        customer_email: { type: string, format: email }
        description: { type: string, maxLength: 500 }
        fiat_amount: { $ref: '#/components/schemas/Money' }
        fiat_currency: { type: string, enum: [USD], default: USD }
        asset: { $ref: '#/components/schemas/Asset' }
        network: { $ref: '#/components/schemas/Network' }
        receiving_account_id: { type: string, description: Exactly one of receiving_account_id or xpub_wallet_id. }
        xpub_wallet_id: { type: string }
        expires_in_hours: { type: integer, minimum: 1, maximum: 720, default: 72 }
        tolerance_amount: { $ref: '#/components/schemas/Money' }
        client_reference_id:
          type: [string, "null"]
          maxLength: 255
          description: Merchant-provided user/order correlation id, echoed unchanged.
        metadata:
          type: object
          default: {}
          description: Merchant-provided JSON object, echoed unchanged; maximum encoded size 4096 UTF-8 bytes.
          additionalProperties: true
        purpose:
          type: string
          enum: [payment, credit_topup]
          default: payment
      oneOf:
        - required: [receiving_account_id]
        - required: [xpub_wallet_id]
      anyOf:
        - required: [customer_id]
        - required: [customer_name]

    Order:
      type: object
      required:
        - object
        - public_id
        - livemode
        - merchant_name
        - reference
        - customer_id
        - customer_name
        - customer_email
        - description
        - fiat_currency
        - fiat_amount
        - asset
        - network
        - crypto_amount
        - receiving_address
        - status
        - amount_received
        - tolerance_amount
        - payment_difference
        - settlement_basis
        - source
        - client_reference_id
        - metadata
        - purpose
        - issued_at
        - expires_at
        - created_at
        - paid_at
        - hosted_url
        - payments
      properties:
        object: { type: string, const: invoice }
        public_id: { type: string }
        livemode: { type: boolean }
        merchant_name: { type: string }
        reference: { type: string }
        customer_id: { type: [string, "null"] }
        customer_name: { type: string }
        customer_email: { type: [string, "null"] }
        description: { type: string }
        fiat_currency: { type: string }
        fiat_amount: { $ref: '#/components/schemas/Money' }
        asset: { $ref: '#/components/schemas/Asset' }
        network: { $ref: '#/components/schemas/Network' }
        crypto_amount: { $ref: '#/components/schemas/Money' }
        receiving_address: { type: string }
        status: { $ref: '#/components/schemas/OrderStatus' }
        amount_received: { $ref: '#/components/schemas/Money' }
        tolerance_amount: { $ref: '#/components/schemas/Money' }
        payment_difference: { $ref: '#/components/schemas/Money' }
        settlement_basis: { $ref: '#/components/schemas/SettlementBasis' }
        source: { type: string }
        client_reference_id: { type: [string, "null"], maxLength: 255 }
        metadata: { type: object, additionalProperties: true }
        purpose: { type: string, enum: [payment, credit_topup] }
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        paid_at: { type: [string, "null"], format: date-time }
        hosted_url: { type: string, format: uri }
        payments:
          type: array
          items: { $ref: '#/components/schemas/OrderPayment' }

    OrderPayment:
      type: object
      required: [transaction_hash, network, asset, amount, confirmations, match_method, occurred_at]
      properties:
        transaction_hash: { type: string }
        network: { $ref: '#/components/schemas/Network' }
        asset: { $ref: '#/components/schemas/Asset' }
        amount: { $ref: '#/components/schemas/Money' }
        confirmations: { type: integer }
        match_method: { type: [string, "null"], enum: [exact_amount, sole_open_invoice, manual, billing_exact_amount, simulated, simulated_detection, null] }
        occurred_at: { type: string, format: date-time }

    TestPaymentSimulation:
      type: object
      required: [scenario]
      properties:
        scenario:
          type: string
          enum: [exact, underpaid, overpaid, payment_detected, expired]
        amount:
          oneOf:
            - $ref: '#/components/schemas/Money'
            - type: "null"

    Checkout:
      type: object
      required:
        - object
        - public_id
        - livemode
        - merchant_name
        - logo_url
        - reference
        - description
        - asset
        - network
        - crypto_amount
        - receiving_address
        - status
        - payment_state
        - amount_received
        - purpose
        - payment_method
        - issued_at
        - expires_at
        - paid_at
      properties:
        object: { type: string, const: checkout }
        public_id: { type: string }
        livemode: { type: boolean }
        merchant_name: { type: string }
        logo_url:
          oneOf:
            - { type: string, format: uri, maxLength: 500 }
            - { type: "null" }
        reference: { type: string }
        description: { type: string }
        asset: { $ref: '#/components/schemas/Asset' }
        network: { $ref: '#/components/schemas/Network' }
        crypto_amount: { $ref: '#/components/schemas/Money' }
        receiving_address: { type: string }
        status: { $ref: '#/components/schemas/OrderStatus' }
        payment_state:
          type: string
          enum: [pending, detected, paid, underpaid, overpaid, expired, cancelled]
        amount_received: { $ref: '#/components/schemas/Money' }
        purpose: { type: string, enum: [payment, credit_topup] }
        payment_method: { $ref: '#/components/schemas/CheckoutPaymentMethod' }
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        paid_at: { type: [string, "null"], format: date-time }

    CheckoutPaymentMethod:
      type: object
      required:
        - kind
        - chain_id
        - chain_name
        - native_currency
        - rpc_urls
        - explorer_urls
        - token_contract
        - token_decimals
        - atomic_amount
        - qr_payload
        - wallet_pay_supported
      properties:
        kind: { type: string, enum: [evm, solana, tron] }
        chain_id: { type: [integer, "null"] }
        chain_name: { type: string }
        native_currency:
          type: [object, "null"]
          additionalProperties: true
        rpc_urls:
          type: array
          items: { type: string, format: uri }
        explorer_urls:
          type: array
          items: { type: string, format: uri }
        token_contract: { type: string }
        token_decimals: { type: integer }
        atomic_amount: { type: string, pattern: '^[0-9]+$' }
        qr_payload: { type: string }
        wallet_pay_supported: { type: boolean }

    CheckoutEventCreate:
      type: object
      required: [event, session_id]
      properties:
        event:
          type: string
          enum: [checkout_opened, wallet_connected, qr_shown, payment_submitted]
        session_id:
          type: string
          minLength: 16
          maxLength: 64
          pattern: '^[A-Za-z0-9_-]+$'

    Wallet:
      type: object
      required: [object, public_id, network, asset, address, label, confirmations_required, active]
      properties:
        object: { type: string, const: wallet }
        public_id: { type: string }
        network: { $ref: '#/components/schemas/Network' }
        asset: { $ref: '#/components/schemas/Asset' }
        address: { type: string }
        label: { type: string }
        confirmations_required: { type: integer }
        active: { type: boolean }

    ListEnvelope:
      type: object
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: {} }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    Event:
      type: object
      required: [id, object, type, created, api_version, livemode, data]
      properties:
        id: { type: string, pattern: '^evt_[a-f0-9]{32}$' }
        object: { type: string, const: event }
        type:
          type: string
          enum:
            - invoice.created
            - invoice.payment_detected
            - invoice.paid
            - invoice.underpaid
            - invoice.overpaid
            - invoice.expired
            - invoice.cancelled
        created: { type: string, format: date-time }
        api_version: { type: string }
        livemode: { type: boolean }
        data:
          type: object
          properties:
            invoice: { $ref: '#/components/schemas/Order' }
            payment: { $ref: '#/components/schemas/OrderPayment' }
          required: [invoice]

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, permission_error, idempotency_error, conflict, rate_limit_error, api_error]
            code: { type: [string, "null"] }
            message: { type: string }
            param: { type: [string, "null"] }
          required: [type, code, message, param]

    ValidationErrorBody:
      type: object
      required: [error]
      properties:
        error:
          type: object
          properties:
            type: { type: string, const: validation_error }
            message: { type: string }
            fields:
              type: array
              items:
                type: object
                properties:
                  loc: { type: array, items: {} }
                  msg: { type: string }
                  type: { type: string }
          required: [type, message, fields]
