openapi: 3.1.0
info:
  title: Scan & Pay API
  version: '2026-05-11'
  summary: PayTo PayID payment sessions, status, and refunds.
  description: |
    Scan & Pay's merchant-facing HTTPS API. Mint a payment session at
    checkout, render the QR Pass, react to our signed webhook, and (when
    needed) initiate refunds against settled payments.

    All amounts are integers in cents (e.g. `1990` = $19.90). The
    legacy `amount` field (float dollars) is still accepted on
    `createPaymentSession` for backwards compatibility but deprecated.

    Only AUD is supported. Session expiry is 5 minutes from creation.
  contact:
    name: Scan & Pay
    email: hi@scanandpay.com.au
    url: https://docs.scanandpay.com.au
  license:
    name: Proprietary
    url: https://scanandpay.com.au/terms

servers:
  - url: https://api.scanandpay.com.au
    description: Production

security:
  - apiKey: []

tags:
  - name: Payments
    description: Create and read payment sessions.
  - name: Refunds
    description: Refund a settled payment.
  - name: Health
    description: Service health check.

paths:
  /createPaymentSession:
    post:
      tags: [Payments]
      operationId: createPaymentSession
      summary: Create a payment session
      description: |
        Mint a new payment session and return the QR Pass + payment URL.
        Idempotent on `(merchantId, platformOrderId)` — reposting the
        same pair returns the existing session unchanged.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/ScanpayVersion'
        - $ref: '#/components/parameters/ScanpaySdk'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSessionRequest'
            examples:
              cents:
                summary: Cents (preferred)
                value:
                  merchantId: merchant_123
                  platformOrderId: order_456
                  amountCents: 1990
                  payId: merchant@payid.com.au
                  merchantName: Acme Coffee
              dollars:
                summary: Float dollars (deprecated)
                value:
                  merchantId: merchant_123
                  platformOrderId: order_456
                  amount: 19.90
                  payId: merchant@payid.com.au
                  merchantName: Acme Coffee
      responses:
        '200':
          description: Session created (or existing returned on idempotent replay).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '500':
          $ref: '#/components/responses/ServerError'

  /getPaymentStatus:
    get:
      tags: [Payments]
      operationId: getPaymentStatus
      summary: Read payment session status
      description: |
        Return the current state of a session. Use this when polling
        — but for server-to-server flows prefer webhooks.
      parameters:
        - in: query
          name: sessionId
          required: true
          schema:
            type: string
            pattern: '^SP_SESS_'
            example: SP_SESS_abc123def456
          description: Session identifier returned from createPaymentSession.
        - $ref: '#/components/parameters/ScanpayVersion'
        - $ref: '#/components/parameters/ScanpaySdk'
      responses:
        '200':
          description: Session found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSessionStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Session not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'

  /createRefund:
    post:
      tags: [Refunds]
      operationId: createRefund
      summary: Initiate a refund
      description: |
        Refund a settled payment session. Partial refunds are supported
        — `amountCents` may be less than the original. Cumulative
        refunds cannot exceed the original payment amount.

        Preconditions: the payment must be `SETTLED` and the PayTo
        agreement must still be `ACTIVE`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/ScanpayVersion'
        - $ref: '#/components/parameters/ScanpaySdk'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRefundRequest'
            example:
              merchantId: merchant_123
              paymentSessionId: SP_SESS_abc123def456
              amountCents: 1990
              reason: customer_request
      responses:
        '200':
          description: Refund initiated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Refund'
        '400':
          description: Validation error or refund would exceed original amount.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/SubscriptionRequired'
        '404':
          description: Payment session not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'

  /ping:
    get:
      tags: [Health]
      operationId: ping
      summary: Health check
      security: []
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: pong

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-Scanpay-Key
      description: |
        Your Scan & Pay API secret. Issued via the merchant dashboard
        or one-time bootstrap. Format: `sp_api_<...>`.

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema:
        type: string
      description: |
        Caller-supplied dedup key. Replaying the same key within 24
        hours returns the existing resource unchanged. SDKs generate
        a UUIDv7 if omitted.
    ScanpayVersion:
      in: header
      name: Scanpay-Version
      schema:
        type: string
        example: '2026-05-11'
      description: Date-stamped API contract version the caller targets.
    ScanpaySdk:
      in: header
      name: X-Scanpay-Sdk
      schema:
        type: string
        example: scanandpay-node/0.4.0
      description: SDK identifier for support correlation.

  schemas:
    CreateSessionRequest:
      type: object
      required: [merchantId, platformOrderId, payId, merchantName]
      properties:
        merchantId:
          type: string
          description: Your merchant ID.
        platformOrderId:
          type: string
          description: |
            Your unique order reference. Forms the idempotency key
            with `merchantId`.
        amountCents:
          type: integer
          minimum: 1
          maximum: 100000000
          description: |
            Total in cents (e.g. `1990` for $19.90). Preferred over
            `amount`. One of `amountCents` or `amount` is required.
        amount:
          type: number
          format: float
          deprecated: true
          description: |
            Total in AUD dollars (e.g. `19.90`). Deprecated — use
            `amountCents`. Kept for backwards compat.
        payId:
          type: string
          description: Your registered PayID (email, phone, or ABN).
        merchantName:
          type: string
          description: Display name on the customer payment screen.
        currency:
          type: string
          enum: [AUD]
          default: AUD
        reference:
          type: string
          description: Customer-visible label.
        source:
          type: string
          enum: [api, woocommerce, shopify, magento, pos, kiosk, shop-app]
          default: api
        metadata:
          type: object
          additionalProperties:
            type: string
          maxProperties: 50
          description: |
            Free-form key/value bag echoed in the webhook. Max 50
            keys, max 500 chars per key + value.

    PaymentSession:
      type: object
      properties:
        success:
          type: boolean
        sessionId:
          type: string
          pattern: '^SP_SESS_'
          example: SP_SESS_abc123def456
        payUrl:
          type: string
          format: uri
          example: https://pay.scanandpay.com.au/p/SP_SESS_abc123def456
        qrUrl:
          type: string
          description: Base64 PNG data URI of the QR Pass — render directly in `<img src=...>`.
          example: 'data:image/png;base64,iVBORw0KGgo...'
        payId:
          type: string
        amountCents:
          type: integer
        amount:
          type: number
          deprecated: true
        currency:
          type: string
          example: AUD
        reference:
          type: string
        status:
          $ref: '#/components/schemas/SessionStatus'
        ui_state:
          $ref: '#/components/schemas/UIState'
        expiresAt:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties:
            type: string

    PaymentSessionStatus:
      allOf:
        - $ref: '#/components/schemas/PaymentSession'
        - type: object
          properties:
            createdAt:
              type: string
              format: date-time
              nullable: true
            paidAt:
              type: string
              format: date-time
              nullable: true
            merchantName:
              type: string

    SessionStatus:
      type: string
      enum: [WAITING, PAID, EXPIRED, FAILED]
      description: |
        - WAITING — customer hasn't paid yet
        - PAID — funds confirmed (terminal)
        - EXPIRED — 5-minute window elapsed (terminal)
        - FAILED — bank rejected or session aborted (terminal)

    UIState:
      type: string
      enum: [AMBER, GREEN, RED]

    CreateRefundRequest:
      type: object
      required: [merchantId, paymentSessionId, amountCents]
      properties:
        merchantId:
          type: string
        paymentSessionId:
          type: string
          pattern: '^SP_SESS_'
        amountCents:
          type: integer
          minimum: 1
          description: |
            Refund amount in cents. Must be ≤ original payment amount
            minus any prior refunds.
        reason:
          type: string
          example: customer_request
        idempotencyKey:
          type: string

    Refund:
      type: object
      properties:
        success:
          type: boolean
        refundId:
          type: string
          pattern: '^SP_RF_'
          example: SP_RF_k1T9DsNSoHnahZIB6
        status:
          type: string
          enum: [INITIATED, ACCEPTED, SETTLED, REJECTED, REVERSED]
        amountCents:
          type: integer
        originalInitiationId:
          type: string
          pattern: '^SP_PI_'
        paymentSessionId:
          type: string
          pattern: '^SP_SESS_'
        idempotencyKey:
          type: string
        idempotent:
          type: boolean
          description: True if a prior request with the same key already created this refund.

    Error:
      type: object
      properties:
        error:
          type: string
        message:
          type: string
        reason:
          type: string
        required:
          type: array
          items:
            type: string

  responses:
    BadRequest:
      description: Validation error — missing field, bad type, or out-of-range value.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid X-Scanpay-Key header.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    SubscriptionRequired:
      description: Merchant subscription is not active.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: SUBSCRIPTION_REQUIRED
              message:
                type: string
              reason:
                type: string
    MethodNotAllowed:
      description: Wrong HTTP method.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServerError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
